Skip to content

Troubleshooting

The failures below are the ones that actually happen on a first run, in the order you’re likely to hit them.

Failed to resolve module specifier "@aparte/core"

Section titled “Failed to resolve module specifier "@aparte/core"”

You opened an index.html directly, with no bundler. A browser cannot resolve a bare specifier on its own — that is a build-tool convention, not a web one.

Two ways out:

  • Use a bundler. Vite, Next, Astro, Parcel, esbuild — any of them resolve it. This is what every snippet in these guides assumes, and what the examples do.
  • Declare the mapping yourself with an import map, and load the CSS by URL:
<script type="importmap">
{ "imports": { "@aparte/core": "https://esm.sh/@aparte/core@latest" } }
</script>
<link rel="stylesheet" href="https://esm.sh/@aparte/core@latest/styles.css" />

“Framework-agnostic” means no React/Vue/Svelte/Angular. It does not mean no build step — and this page exists to say the first thing you actually hit, so it says it.

This is the #1 first-run failure. With AparteDirectTransport (the default), the browser itself calls http://localhost:1234 (LM Studio) or http://localhost:11434 (Ollama) directly — there is no server in between to add CORS headers for you. If the local server doesn’t send permissive CORS headers, the browser blocks the response and the request fails with a network/CORS error, even though the server logs show it received the request.

The two local presets live in packages/providers/ai/openai-compat/src/presets.ts as presets.LMSTUDIO (http://localhost:1234/v1) and presets.OLLAMA (http://localhost:11434/v1) — see the OpenAI-compatible provider guide for how to register them.

Fix — LM Studio: open the LM Studio server settings (Developer tab) and enable “Enable CORS” on the local server, then restart the server.

Fix — Ollama: start it with the OLLAMA_ORIGINS environment variable set to allow your page’s origin (or * for local development):

Terminal window
OLLAMA_ORIGINS=* ollama serve

On Windows (PowerShell), set it before launching:

Terminal window
$env:OLLAMA_ORIGINS = "*"
ollama serve

Without this, Ollama only accepts requests from a small built-in allowlist of origins and rejects everything else with a CORS error.

”No provider registered” / no model available

Section titled “”No provider registered” / no model available”

Two different symptoms, one root cause: nothing is wired up yet.

“Provider is not registered” — you called aparteGlobalConfig.registerAIProvider(...) with a different id than the one selected (or never called it at all). Register the provider and select it before the client sends anything:

import { aparteGlobalConfig, AparteDirectTransport, AparteClient } 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();

“No provider selected” — no defaultProvider/defaultModel is set on aparteGlobalConfig.setModelConfig(...) (and no <aparte-model-selector> has picked one yet). Check aparteGlobalConfig.hasSelectedModel() — it’s false until both are set. If you want the composer to block sending until a model is chosen (instead of erroring on send), opt into aparteGlobalConfig.setRequireModelSelection(true).

Either way, forgetting new AparteClient().start() looks identical to a broken provider from the outside: nothing streams, because nothing is listening for aparte-send. See Wire a real model.

If you see:

[Aparte] AparteDirectTransport is sending the "<provider>" API key straight from the browser —
it is visible to anyone who opens devtools. ...

AparteDirectTransport just sent a real API key from the browser to the vendor, and you didn’t tell it that was intentional. It fires once per page load, the first time a key is attached to a request.

  • Fine to ignore (or silence) when: the key is the end-user’s own (BYOK) or the model runs locally (LM Studio, Ollama) — pass { byok: true } so the warning doesn’t fire at all:

    aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
  • Not fine when: the key is your server-held vendor key. Anyone with devtools open can read it and use it directly. Switch to AparteBackendTransport (paired with createAparteChatHandler) so the key never reaches the client — see the Backend transport guide.

Keyless local providers never trigger this warning — there’s no key to expose.

[Unknown segment type: …] in the bubbles

Section titled “[Unknown segment type: …] in the bubbles”

The bubble found no renderer for that segment type.

  • A built-in type (text, thinking, code, tool_call, error) should never show this: core installs its built-in renderers the first time a segment needs one. If you see it anyway, something declined them — a new AparteClient({ autoRegister: false }) somewhere, which is remembered on purpose. Drop the option, or register what you need with registerSegmentRenderer. On 0.4.x and earlier the built-ins only came with new AparteClient(), so a display-only app had to call registerDefaultRenderers() itself — that’s the fix there.
  • Your own type — that’s the expected fallback: register a renderer for it (see Custom segment types).

The symptom is easy to misread, because everything else works: bubbles, streaming, auto-scroll, the composer. Only the content is missing.

The retry / edit / ⓘ buttons aren’t there

Section titled “The retry / edit / ⓘ buttons aren’t there”

They ship off. Core can only render them; re-sending a message, keeping edited text and opening a stats popover all need someone outside core, so aparté waits for you to say you’re there rather than showing a button that answers to nobody:

aparteGlobalConfig.setBubbleActions({ retry: true, edit: true }); // you run an AparteClient
aparteGlobalConfig.setBubbleActions({ feedback: true, info: true }); // you handle these events

Same for the affordance outside the action bar — the clickable image tile:

aparteGlobalConfig.setHostHandlers({ attachmentPreview: true });

Two things that are not the cause, before you go looking:

  • The bar hides itself while a reply streams (and reappears when the turn ends) — by design, so copy/retry never sit on an empty bubble.
  • An action bar with nothing in it isn’t rendered at all, so if you disabled every action the whole row is gone rather than blank.

Coming from 0.4.x and the buttons vanished? That’s this change — one line brings them back. The full table of what ships enabled and why is in Customization.

Three causes, in the order they happen.

You are passing footerLeft / footerCenter / footerRight. They were removed: the three positional slots became one toolbar. In React that is a type error; in Vue, Svelte and Angular an unknown slot name renders nothing, silently, which is why this entry exists. Pass one toolbar and order your controls yourself — see The composer toolbar.

The row is empty. <aparte-composer-toolbar> reflects data-empty while it holds no content, and the stylesheet hides it then — an empty row must not draw its separator. Text counts as content, so a bare token count is fine; whitespace and comments do not. If you expected something in it and see nothing, inspect the element: data-empty present means your content never arrived (a mistyped slot name, or a framework that rendered nothing).

The row is there but unstyled, and your control sits at the start. Then the stylesheet did not reach the page: an undefined-looking custom element falls back to display: inline, so there is no flex container and margin-inline-start: auto does nothing. Check that you import @aparte/core/styles.css, and — if you work in this repo — that packages/core/dist/index.css is current: it is a build output, so a stale one can be served after a build that reported success.

Every failure that reaches the UI — a bad request, a rate limit, a network drop, an unregistered provider — is normalized to an AparteError (packages/core/src/types/errors.ts):

declare class AparteError extends Error {
constructor(
message: string,
code: AparteErrorCode,
data?: Record<string, unknown>,
originalError?: unknown,
httpStatus?: number,
);
// `defaultCode` defaults to AparteErrorCode.UNKNOWN_ERROR.
static from(error: unknown, defaultCode?: AparteErrorCode, defaultStatus?: number): AparteError;
}

AparteErrorCode is a flat enum grouped by who’s responsible:

CodeMeaning
CONFIG_NO_PROVIDERNo provider selected.
CONFIG_MISSING_KEYThe selected provider isn’t registered (or its key is missing).
CONFIG_INVALID_KEYThe vendor rejected the key (HTTP 401 / 403).
CONFIG_INVALID_MODELThe selected model id isn’t valid for the provider.
USAGE_RATE_LIMITVendor rate limit (HTTP 429).
USAGE_CONTEXT_EXCEEDEDContext window exceeded (HTTP 400).
USAGE_BAD_REQUESTMalformed request (HTTP 400).
NET_OFFLINEThe client is offline.
NET_TIMEOUTThe request timed out.
NET_ERRORGeneric network failure.
PROVIDER_ERRORVendor-side error (HTTP 5xx).
PROVIDER_UNAVAILABLEVendor service unavailable (HTTP 503).
PROVIDER_POLICYRejected by the vendor’s moderation/policy.
UNKNOWN_ERRORAnything uncategorized — AparteError.from’s default.

How it surfaces: a non-ok response makes AparteDirectTransport and AparteBackendTransport throw an AparteError — the vendor’s message, the HTTP status in httpStatus, and the code that status stands for: 429USAGE_RATE_LIMIT, 401 / 403CONFIG_INVALID_KEY, 503PROVIDER_UNAVAILABLE, other 5xxPROVIDER_ERROR, 400USAGE_BAD_REQUEST, 408NET_TIMEOUT (AparteError.codeForStatus() is that table, for a provider that wants the same). Anything else thrown along the way is wrapped by AparteError.from(error), which reads the same table off an error’s own status when it has one, reads fetch’s network failure as NET_ERROR (NET_OFFLINE when the browser says so) and a TimeoutError as NET_TIMEOUT, and settles on UNKNOWN_ERROR only when nothing says more. AparteClient renders it as the message’s error segment (content = error.message, details = error.code), and dispatches an aparte-message-error CustomEvent on the target element with { messageId, error }error is the full AparteError, so error.code, error.data, error.httpStatus and error.originalError are all available to a listener:

document.querySelector('aparte-chat')?.addEventListener('aparte-message-error', (e) => {
const { error } = (e as CustomEvent).detail;
console.error(error.code, error.message);
});

Customize what the error segment looks like with aparteGlobalConfig.setErrorRenderer rather than registering a segment renderer for error yourself.