AI chat Web Components, framework-agnostic — with the agent loop inside

Chat, spoken aside.

Not a bubble kit. One element gives you the whole turn: 6 kinds of content, human-in-the-loop tools, branch-and-retry on any reply — with zero third-party dependencies. React, Vue, Svelte, Angular, or nothing at all.

$ npm i @aparte/core

Drop <aparte-chat> into any web app, point it at a model, and the defaults are a working chat. Customize only what you need.

// an assistant reply is not a string
message.segments = [
  { type: 'thinking',  … },
  { type: 'tool_call',
    status: 'awaiting-approval' },
  { type: 'code',      … },
  { type: 'text',      … },
]
i.Zero third-party dependencies ii.6 kinds of content in one turn iii.415 CSS variables, no shadow DOM iv.Your key (BYOK), your server — or no server at all v.24 chat components, 25 UI-kit families

assistant · running here, in this page
source only the two lines that mount it differ — everything here is the same in React, Vue, Svelte and Angular. See the mount →
// the whole chat, plus the files the composer collects
import '@aparte/core';
import '@aparte/core/styles.css';

<aparte-chat attachments></aparte-chat>

composer.addEventListener('aparte-send', (e) => {
  const { content, files } = e.detail;
  viewport.appendMessage({ id, role: 'user', content });
  // …then stream your model's reply back in
  viewport.appendToken(id, chunk);
  viewport.completeMessage(id);
});
// a tool that stops the turn until a person decides
aparteGlobalConfig.registerTool({
  name: 'delete_file',
  description: 'Delete a file from the project',
  inputSchema: { /* JSON Schema */ },
  needsApproval: true,
}, async ({ path }) => remove(path));

// AparteClient asks at the composer and resumes for you.
// Ask it yourself the same way — no client, no loop:
const answer = await aparteGlobalConfig.requestUserInput({
  kind: 'approval',
  message: 'Run delete_file?',
  options: [
    { value: 'allow', label: 'Approve', tone: 'affirm' },
    { value: 'deny', label: 'Reject', tone: 'deny' },
  ],
});
// two kinds in one reply, both streamed:
// open empty, append, then settle
viewport.appendMessage({ id, role: 'assistant' });

viewport.addSegment(id, { id: 's1', type: 'thinking',
  content: '', isStreaming: true });
viewport.appendToSegment(id, 's1', chunk);
viewport.updateSegment(id, 's1', { isStreaming: false });
// closed by default: `collapsed` is the reader's
// business, never the model's. You never write
// messageId or index either — the owner stamps
// them, and its measurements land in meta.aparte.
// the model wrote a document; the artifacts plugin renders the card
viewport.addSegment(mid, {
  id: 'a1', type: 'artifact',
  artifactType: 'svg',
  mimeType: 'image/svg+xml',
  title: 'Turn latency by segment',
  content: svgTheModelWrote,
});

// The Preview tab mounts a sandboxed iframe —
// sandbox="allow-scripts", a CSP delivered twice, and
// ONLY on a human press. Reloading a saved thread
// never re-runs what a model wrote.
// only `copy` is on by default — a button nobody
// answers is a lie told to the user
aparteGlobalConfig.setBubbleActions({ retry: true });

// retry forks a sibling instead of overwriting,
// and the ‹1/2› picker appears on its own
document.addEventListener('aparte-retry', (e) => {
  const id = viewport.addSiblingOf(e.detail.messageId, {
    id: next, role: 'assistant', content: '',
  });
  // …then stream the new version into `id`
});
Palette Stage — follows this page Violet — pinned dark Paper — pinned light Icons Default — what core ships Solid Hard-edged Language English Français

Everything above runs on @aparte/provider-scenario — scripted turns played as a real stream, no key, no network, deterministic. Ship your own demos and e2e on it.

What a turn (contains)

A reply is not a string — it is a structured turn. A list of segments, and 6 kinds ship registered by default — mixable in a single message, each with its own renderer you can replace. This is the unit a presentational component kit has no way to express.

text

Streamed token by token. Markdown and highlighting are plugins you opt into, so the core stays dependency-free.

thinking

A collapsible block that closes itself the moment the model stops thinking — not when the answer ends.

code

A fenced block with a copy button that copies the source, not the markup.

tool_call

Five states, including a turn paused for a person — the row is the anchor, the decision is asked at the composer. It opens onto the arguments the model sent and the result your handler returned.

pendingawaiting-approvalresolvedrejectedaborted

error

A failure card inside the turn that produced it — not a toast somewhere else on the page.

artifact

A plugin's segment, not core's — tabbed Code / Preview from @aparte/plugin-artifacts. The preview runs in a sandboxed iframe, and only ever after a person presses Preview.

Every segment also knows where it is — its message, its index, and a span that advances while it streams and freezes when it settles, so “Thought for 1.4s” is data you read rather than a timer you keep. Core stamps it and renders none of it. Register your own kind →

A human, (in the loop)

The part that makes this a library and not a widget: the turn can stop and wait for a person, and core draws that pause itself.

  • Mark a tool needsApproval and the loop pauses before the handler runs. Core asks at the composer, where every request for the user is answered, and the row in the transcript is the anchor. A refusal skips the rest of the turn and then the model gets a turn to answer it — and it can carry your own words.
  • Ten tool round-trips per turn by default, overridable per run and per tool.
  • A turn can also ask a structured question — a schema-driven panel in the composer, and the question with its answer stays in the transcript afterwards.
  • Abort is checked on both sides of every stream read, and each tool call can race its own timeout.
// the loop stops before the handler and asks in the composer;
// a refusal skips the rest of the turn, then the model answers it
aparteGlobalConfig.registerTool(
  {
    name: 'delete_file',
    description: 'Delete a file from the project',
    inputSchema: { /* JSON Schema */ },
    needsApproval: true,
  },
  async ({ path }) => remove(path),
);

Branch (any turn)

Retry does not overwrite an answer. It forks a sibling, and a picker appears so a reader can move between versions — the same behaviour every serious chat product has, built in rather than rebuilt.

Try it on the chat above: send a message, hover the reply, press retry. The two versions stay side by side behind the counter, and nothing is lost.

Bubble actions are off until you handle them, which is the rule the whole library follows: a button nobody answers is a lie told to the user. Copy works with core alone, so copy is the only one on by default.

// retry is honoured by whoever runs the loop —
// AparteClient, or this page's own listener
aparteGlobalConfig.setBubbleActions({ retry: true });

// and a fork is one call on the viewport
viewport.addSiblingOf(messageId, next);

Make it (yours)

415 custom properties, and no shadow DOM anywhere in core — so there is no boundary to pierce. A plain selector reaches any node, and devtools shows you the real one. Four levers, in order of how far they go:

  • Variables. Colours, spacing, radii, font sizes, border widths — and one master, --aparte-primary, read in 40 places across the stylesheets: 13 other variables derive from it, and the rest of the library reads it directly. Set it on :root and the whole accent follows.
  • Icons. An icon provider, not a hardcoded set: setIconProvider() takes yours and re-renders what is already on screen, so a set can be swapped live.
  • Language. A locale is a plain object of 88 strings handed to setLocale(). Your own language needs no package and no pull request here.
  • Renderers. When a variable is not enough — a reasoning block you want as one line rather than an accordion — replace the renderer. Structure is not hostage to a stylesheet.
/* one instance, three variables */
aparte-chat {
  --aparte-accent: #c9a227;
  --aparte-bg: #14101c;
  --aparte-avatar-radius-ratio: 0.2;
}

/* light DOM: an ordinary selector, no ::part() */
.aparte-segment-tool-call .aparte-tool-label {
  font-variant: small-caps;
}

Every variable, listed →

Five ways to (mount it)

It is one web component underneath, so the rendered chat is identical everywhere. What changes is the two lines you write.

All four wrappers are published today, and the same browser suite drives them next to the vanilla build. Core also ships a Node entry point, so importing types on a server does not need a DOM.

What you (plug in)

21 packages, 5 families. Everything past the core is something you choose — nothing arrives because it might be useful.

Core

two packages
  • @aparte/coreThe web components, the renderers, the transports. Zero third-party dependencies.
  • @aparte/engineThe agent loop on its own, drivable from your code. A test proves both loops behave identically.

Frameworks

four wrappers, published
  • @aparte/reactComponent, hooks, a conversation-manager hook.
  • @aparte/vueComponent, composables.
  • @aparte/svelteComponent, stores.
  • @aparte/angularStandalone component, services, one-call provideAparte().

Providers

four
  • @aparte/provider-openai-compatOne zero-dependency adapter for the whole OpenAI-compatible family, with presets for OpenAI, Mistral, Z.AI, OpenRouter, LM Studio and Ollama.
  • @aparte/provider-ai-sdkA bridge to any Vercel AI SDK model — install that vendor’s own adapter and it runs under aparté’s loop.
  • @aparte/provider-transformersThe model runs in the browser. WebGPU with a WASM fallback, in a worker, weights cached and evicted through the Cache API. No server, no key.
  • @aparte/provider-scenarioScripted turns played as a real stream — thinking, tool calls, artifacts, paced. Every demo on this page runs on it; your demos and e2e can too.

Plugins

five
  • @aparte/plugin-markedMarkdown, rendered once at the end of a message.
  • @aparte/plugin-streaming-markdownMarkdown while it streams — and it strips a javascript: href mid-stream, not only at the final render.
  • @aparte/plugin-shikiSyntax highlighting, with a second entry point for consumers who want to control what their bundler emits.
  • @aparte/plugin-model-selectorA real ARIA combobox, grouped by provider, keyboard-operable.
  • @aparte/plugin-ask-userThe ask_user tool: the model asks a structured question, the answer stays in the transcript.

Locales

one shipped — and any language is 88 strings
  • @aparte/locale-frFrench, complete. Hand it to setLocale() at runtime; resetLocale() goes back to English.
  • setLocale({ … })Your own language needs no package, no build step and no PR here: a locale is a plain object of 88 strings, and English ships inside core as the default.

And it is (checked)

An alpha, said plainly — the API can still change before 1.0. What does not change is how it is verified.

268test files, over all 21 published packages2,866 tests, all green
29Playwright specs in real Chromium, WebKit and Firefox
31gate steps, 24 of them guards — one checks the CI runs the rest
axeaccessibility scans on an idle chat, a stream, an open dropdown, a failure

The API reference on this site is generated from the components themselves at build time, so it cannot drift from the code. Read the reference →