Skip to content

Changelog

Version 0.8.0

Every @aparte/* package ships at this version (they are released in lockstep).

Minor Changes

  • c33d2b0: A fourth cold audit: two CRITICALs, fourteen MAJORs, and the guards that let four of them in

    Same protocol as the third — five auditors, no changelog, no git history. It found less on the surface and more underneath, which is the only progress worth reporting: the two CRITICALs were both in code less than two days old, and four of the MAJORs were defects in the guards rather than in the library.

    The two CRITICALs share a root: { config } scoped what a chat READ and not what it ANSWERED. AparteClient listens on window, and its only instance filter was scopeToTargetId; unset, the guard returned true for everything. Two config-scoped clients on one page therefore both ran a full agentic turn for every send — two provider calls, two paid completions, both replies appended into the single target the event named. A config-scoped client now declines a target whose boundary resolves a different, non-global config; a client on the global config still answers everything, which is every single-chat app. The second: opening a conversation revoked its own attachments’ object URLs, because clearAll() releases them and both setMessages and importTree put the messages straight back — and export() stores live references, so the two views share the very same attachment objects. Every image and file chip was dead on load.

    Three MAJORs in the turn. A mid-stream error event erased everything already rendered: _handleLifecycleError replaced the segments instead of appending, so a partial answer plus an error became an empty bubble with an error in it. toolTimeoutMs could not time anything out — all three copies aborted a signal and then awaited the handler with no race, and aborting is a request a handler may ignore, which the default shape of a consumer tool does. Core’s two copies now share withToolTimeout. And the engine compactor could emit a window opening on role: 'tool', which every OpenAI-compatible provider rejects with a 400: compaction turned a long conversation into an unusable one.

    Consent is scoped to the chat that asked. Human-in-the-loop approval matched on the model-chosen toolCallId and nothing else, on a document listener, with built-in buttons that bubble and compose — so on a page with two chats, a click aimed at one tool could satisfy the gate awaiting a different tool in a different conversation. The check is now DOM containment: a model can choose an id, it cannot choose where a click happened. A programmatic dispatch from a host is still honoured.

    Three more surfaces where per-instance config did not reach what it configures. injectRendererStyles() collected the global’s styles over an instance config’s, so a renderer registered on a config drew unstyled and silently; it now takes the config and accumulates rather than assigns, and re-creates its <style> when the old one has been detached rather than only when it is null. setupMarkedProvider(options) scoped the provider and not the options — marked.use() mutates a module singleton cumulatively, so configuring the second chat retroactively changed the first’s rendering. And the global type augmentations reached the browser entry only, so an SSR consumer silently lost typed e.detail on every aparté event.

    Every AparteChat accepts a caller-supplied host id. scopeToTargetId matches detail.targetId, which the wrappers set from an id they generated and neither accepted nor exposed — so the documented mechanism was unreachable from three of the four components. React, Vue and Svelte gain an optional id prop; Angular already honoured one. The generated id remains the default.

    The artifact preview stops overclaiming. Its comment said everything leaving the frame is blocked. The fetch half is true and measured in three engines; the frame navigating ITSELF is not a fetch and no directive governs it — navigate-to was removed from the spec and never shipped. No CSP or sandbox token stops it, so the fix is the claim, plus a danger block on setArtifactPreviewBuilder, which was one line that never mentioned it replaces the policy while recommending CDN libraries.

    Four MAJORs were the guards. check-doc-snippets compiled with a WEAKER profile than the repo compiles itself with — no noUncheckedIndexedAccess, noImplicitReturns, noFallthroughCasesInSwitch or noImplicitOverride — so a snippet could be certified while a reader’s build, following this project’s own recommended settings, rejected it. Aligned, it immediately failed the flagship getting-started example. check-export-mentions could not read a barrel written as export *, saw 4 names for the engine’s 39-name surface, and then certified the package already at zero unmentioned; it also credited a short export whenever a longer documented name merely contained it, and its list of barrels omitted the plugins, providers and locale-fr. check-node-barrel-types diffed export names, which an augmentation module has none of. check-wrapper-slots proved nothing about the host id. All four now bite, verified by sabotage one at a time, and the export guard gained the SEEN floor that a collapsed count needs — the third guard in this repo to need it for the same reason.

    The wrapper reference has examples. Eleven of the sixteen slot × framework combinations appeared in no code block anywhere, in a page whose own history is the reason this project has a rule about capabilities cited in passing. The page is generated, so each slot now emits one fence per framework from the same table as the syntax column. @aparte/core, @aparte/engine, @aparte/plugin-marked, @aparte/react, @aparte/svelte, @aparte/vue

  • 688a231: Remediation of a from-scratch audit: four CRITICAL and nineteen MAJOR defects, plus the guards that make each class unrepeatable.

    Fixes you will notice

    • Pressing Stop no longer erases the answer. A stopped turn replaced everything already streamed with an error bubble, and never dispatched aparte-message-aborted. Three separate paths had to be closed: an abort arriving while the loop was parked on its read, openai-compat reporting an AbortError as a stream error where ai-sdk stays quiet, and a rejection escaping transportCall before the first event.
    • A code fence split across deltas no longer eats the text before it, and no longer leaks a literal ```python into the message.
    • A split <artifact tag no longer loses its whole lifecycle. < and artifact are separate tokens in most vocabularies, so whether artifact events fired depended on where the tokenizer cut.
    • A turn the human stopped, stops. A rejected tool no longer lets the rest of that turn’s tool calls run.
    • compact() only touches its own chat. With two clients on a page, one event made both summarise the same conversation and wiped the other with no summary.
    • Retrying the first message no longer resends the whole conversation. Viewport listeners no longer accumulate when the element is moved in the DOM. A stream we walk away from is cancelled rather than left generating.

    Breaking

    • A previewable artifact no longer runs the model’s code without a user gesture. The card opened on Preview with the frame already mounted, so every render of a completed artifact — including reloading a persisted conversation — executed model-authored JS. It is sandboxed, so this was a prompt-injection surface rather than origin XSS. The frame is now created only when the user presses Preview, and is CSP-constrained. An app that wants it open must open the tab itself.

    • authorize is required on createAparteChatHandler. The endpoint spends your server-held key, and both the JSDoc example and the docs snippet omitted it — the copy-paste path was the unauthenticated one. authorize: () => true still works, but now someone wrote it on purpose. Vendor error bodies are summarised instead of relayed, because an OpenAI 401 hands the caller your key’s prefix and tail.

    • streamRunner: runStreamAgent finally typechecks. Making it compile required narrowing role, mirroring the content-part union and the tool types, declaring modelId, and removing three index signatures from @aparte/engine’s mirror types. A consumer who wrote their own StreamAgentMessage may need to adjust.

    • @aparte/engine no longer re-exports deriveArtifactKind — it collided with @aparte/core’s export of the same name, with a different function behind it.

    • escapeHtml / escapeAttr, AparteHostHandlersConfig and AparteKeyProvider are now exported from @aparte/core.

    • Ten exports are renamed, before 1.0 makes their names permanent. The four classes gain the prefix every other class already carried, and the six shared defaults gain a namespace so they cannot collide with an app’s own:

      beforeafter
      DirectTransportAparteDirectTransport
      BackendTransportAparteBackendTransport
      MessageRepositoryAparteMessageRepository
      ConversationManagerAparteConversationManager
      DEFAULT_LOCALEAPARTE_DEFAULT_LOCALE
      DEFAULT_UI_EVENTSAPARTE_DEFAULT_UI_EVENTS
      DEFAULT_ICON_FALLBACKSAPARTE_DEFAULT_ICON_FALLBACKS
      DEFAULT_BUBBLE_ACTIONSAPARTE_DEFAULT_BUBBLE_ACTIONS
      DEFAULT_HOST_HANDLERSAPARTE_DEFAULT_HOST_HANDLERS
      DEFAULT_SKELETON_FALLBACKSAPARTE_DEFAULT_SKELETON_FALLBACKS

      Functions keep their verb names — registerDefaultRenderers, contentToText, filesToAttachments and the rest are unchanged, because prefixing a verb reads worse than the inconsistency it would fix.

    • The config naming is inverted: AparteConfig is now the class, and the page-wide instance is aparteGlobalConfig. AparteConfigClass is gone.

      import { AparteConfig, type AparteConfigClass } from '@aparte/core';
      AparteConfig.setMarkdownProvider(provider);
      function configure(config: AparteConfigClass) {}
      import { aparteGlobalConfig, type AparteConfig } from '@aparte/core';
      aparteGlobalConfig.setMarkdownProvider(provider);
      function configure(config: AparteConfig) {}

      One sed covers a whole consumer, and the order matters — run the instance first, so that \bAparteConfig\b cannot yet match the class:

      Terminal window
      # 1. the instance, then 2. the class. Never the reverse.
      grep -rlE '\bAparteConfig(Class)?\b' src \
      | xargs perl -pi -e 's/\bAparteConfig\b/aparteGlobalConfig/g'
      grep -rl 'AparteConfigClass' src \
      | xargs perl -pi -e 's/\bAparteConfigClass\b/AparteConfig/g'

      It rewrites string literals too, so a log prefix of your own like [AparteConfig] becomes [aparteGlobalConfig] — harmless, but check your diff if you grep your logs.

      Two reasons this happens now rather than never. AparteConfigClass was not a name, it was an admission — the Class suffix existed only because the good name was taken by an object, and PascalCase means constructor everywhere else in the ecosystem, so AparteConfig.setMarkdownProvider(...) read as a static method to every reader. And the library moved under it: since config became per-instance, the global singleton is one config among several and the one we recommend least. Giving it the canonical name pointed at the case we want people to outgrow; aparteGlobalConfig says at every call site which config you are touching.

    • @aparte/provider-transformers: terminateWorker() no longer bricks the provider. Called while a generate was in flight, it dropped the pending streams but left their serialization slots unresolved — so the next chat() awaited a promise that could never settle. No error, no rejection: the stream simply never started again, for the life of the page. The worker-error path already released those slots, with a comment explaining why; terminateWorker, 240 lines below it, did not.

      Its state is still tab-scoped, on purpose, and now says so: one worker, one loaded model, one generate at a time, with setComputeDevice / setMaxCachedModels / setHardwareTierModels applying page-wide. A local model is 1–2 GB of weights and one WebGPU pipeline, so a worker per chat would mean N copies resident in one tab — the failure this package exists to avoid. Two chats on the same model share the load, which is the case it is for. Two chats on different models serialize and, at the default budget of one cached model, can evict and reload gigabytes between turns — that used to happen in silence and now warns once, naming both models.

      TransformersProvider.chat is also declared non-optional now, so consumers stop needing provider.chat!(...).

    • Seven documented event contracts are gone, because those events never existed. Not “undocumented” — the name appeared in the repo only in its own declaration. aparte-artifact-open sat in the event map with a detail type asserting it is “dispatched by the artifact pill when a user clicks it”; three hits repo-wide, all three its own declaration.

      removedwhy
      AparteTokenEventDetailaparte-token is dispatched nowhere
      AparteMessageEventDetailaparte-message is dispatched nowhere
      AparteStatusEventDetailaparte-status is dispatched nowhere
      AparteToolActionDetailits JSDoc names aparte-tool-action, which does not exist
      AparteSegmentActionEventaparte-segment-action does not exist
      AparteConversationUnarchiveDetaila dead type on a live event — the dispatcher types both archive branches with AparteConversationArchiveDetail

      Two were renamed rather than deleted, because their shape was right and only the event they named was wrong:

      import type { AparteArtifactOpenEventDetail, AparteSegmentUpdateEvent } from '@aparte/core';
      import type { AparteArtifactRedownloadEventDetail, AparteSegmentUpdateEventDetail } from '@aparte/core';

      AparteArtifactRedownloadEventDetail is field-for-field what the Download button really dispatches. AparteSegmentUpdateEventDetail was a detail, not an event, and had never reached a package entry point at all — so you could bind an event listed in the published API table and never name its detail. Same for AparteConversationArchiveDetail, now exported.

    • Twenty events gained a typed detail, so the cast the docs promised you would never write is finally unnecessary. The map carried 17 entries against 37 events that dispatch a detail. Fourteen had no declared type anywhere — including aparte-file-gen-ready / -error, where core renders a “Running sandbox…” card and waits on window for an event nothing in the library emits, so a consumer had to reverse-engineer six fields from an inline cast to make a binary artifact ever finish. Six more had a public detail type and still forced a cast at every listener.

      Additive for your code, and check:event-map now enforces both directions: an event with a detail must be in the map, and a map entry must correspond to a real event.

    • AparteThemeVariables is { [K in —aparte-${string}]?: string }. It was a hand-written list of 33 CSS properties, ten of which are neither declared nor read anywhere in aparté — so it autocompleted ten knobs that do nothing — while the real surface is 254 tokens. You lose autocomplete and gain a type that cannot lie; the 231 tokens the old list omitted, the whole aparte-select surface included, now typecheck. The discoverable list is the generated CSS-variables reference.

    • One name for the imperative surface: AparteChatImperativeApi. React exported it as AparteChatHandle, Vue and Svelte as AparteChatInstance, and Angular exposed no name at all — one contract wearing three names in a suite that publishes all four together in lockstep. Both aliases are gone; every wrapper now re-exports the canonical type straight from @aparte/core, which is also where its documentation lives, so the names cannot drift again.

      import { AparteChat, useAparteChat, type AparteChatHandle } from '@aparte/react';
      import { AparteChat, useAparteChat, type AparteChatImperativeApi } from '@aparte/react';

      Same rename for AparteChatInstance in @aparte/vue and @aparte/svelte. Nothing about the shape changed — it was already an alias of the same type in all three.

    • AparteAIProvider is a union instead of one permissive interface. It had three required members and fifteen optional ones, holding two mutually sufficient execution surfaces — a chat() that owns its own I/O, or the format-adapter surface a transport drives — discriminated at runtime by isFormatAdapter(). So { id, getMetadata, getModels } typechecked, registered without a word, and failed on the first message. A half-built adapter (buildRequest but no parseStream) did the same, and so did a complete adapter with no way to present a key.

      Now the compiler answers “which half did you implement?”. Every member of both surfaces stays reachable on the union — optional on the arm that does not require it — so typeof p.buildRequest === 'function' probes and isFormatAdapter() narrowing are unchanged, and a provider implementing both surfaces is still valid. If your provider was complete it compiles as before; if it was one of the shapes above, it never worked.

    • Every satellite’s peer on @aparte/core is now the lockstep range (~0.8.0) instead of >=0.5.0-alpha.0 <1.0.0. The suite has always published in lockstep, so that range described a compatibility promise nobody was making or testing: npm was happy to install @aparte/react@0.8.0 beside @aparte/core@0.5.0, and the failure landed at runtime with no warning at install. This very release makes the point — under the old range, @aparte/react@0.8.0 would install against @aparte/core@0.7.1 and then fail on an aparteGlobalConfig that does not exist there.

      If you install the packages together, or with latest, nothing changes. If you were pinning @aparte/core behind the wrappers, npm now tells you at install time instead of at first render.

    • @aparte/svelte ships its .svelte sources instead of a precompiled bundle, and supports Svelte 4 and 5 (^4.0.0 || ^5.0.0). Nothing to change in your code, unless you were importing from a deep path inside dist.

    • Every plugin setup* takes an optional trailing config, so a plugin can be scoped to one chat instead of the global singleton. Existing calls are unaffected.

    • AparteClient accepts toolTimeoutMs, matching runStreamAgent’s option of the same name — it was previously a hard-coded constant, so setting it worked on one loop only.

    Security

    Nine private copies of the HTML-escaping helper became one; three of them had drifted to leave the apostrophe through, which is enough to break out of a single-quoted attribute. 42 unescaped attribute interpolations were swept (the audit reported 3). Segment lookups are scoped to their own children, so a decoy data-segment-id in model markdown can no longer hijack a human-in-the-loop control. A style declaration containing a backslash is rejected outright, and a data: image URL must name its subtype.

    srcset now goes through the same URL allowlist as src. It had only a javascript:/vbscript: substring test, so srcset="data:text/html,<script>..." passed untouched while the identical URL on src was rejected — one allowlist giving two answers depending on which attribute carried the URL. Each scheme in the value is validated rather than splitting on commas, because a legitimate base64 data: URL contains one.

    data:image/svg+xml is deliberately KEPT in the allowlist, contrary to an earlier plan to drop it: inside an <img> a data-URL SVG is secure-static in every engine (no scripts, no external fetches), and removing it would break a model emitting an inline chart. What it must not do is travel — an app that moves such a URL into an <object>, <embed> or an iframe leaves secure-static mode, and that constraint belongs to whoever re-hosts it.

    Also in this release

    • Elicitation stops inventing refusals. With no presenter registered, requestUserInput() resolved { action: 'cancel' } in silence: your tool reported a refusal the user was never asked for, and the model answered as though they had declined. It still resolves cancel (a question nobody can render cannot be awaited) but now warns once, and the guide shows the <aparte-elicitation> element you must place — it registers itself on connect, so nothing happens until it is in your markup.

    • @aparte/svelte publishes resolvable types. types was not first in its exports block, and export conditions are order-sensitive, so TypeScript could not resolve the package’s types at all. The svelte condition now carries its own types, matching how core’s node condition is built.

    • escapeHtml / escapeAttr / cssEscape are documented with an example instead of being mentioned in passing: which one belongs in markup, which in an attribute, which in a selector, and why the apostrophe matters. They were exported all along while a comment in their own file claimed they were internal.

    • AparteClientOptions.toolTimeoutMs is in the config reference. The config argument every plugin setup* takes is documented with an example — it was named nowhere, and it is what lets two chats on one page use different providers.

    • Segment renderers are per config too, so the config prop is now honest end to end. registerSegmentRenderer, unregisterSegmentRenderer, getSegmentRenderer, getAllRenderers, collectRendererStyles, registerDefaultRenderers and declineDefaultRenderers all take an optional trailing config; omitted, they act on the ambient or global one exactly as before, so no existing call changes.

      This was the other half of the wrappers’ promise. A plugin’s providers were already scoped, but the registry deciding WHICH renderer draws a segment was a module-level Map — two chats on a page shared their segment renderers whatever config they were given. AparteClient({ config, autoRegister: false }) was affected the same way: it declined the built-ins on the global config rather than on its own, muting the wrong chat.

    • thinkingDelimiters is documented, including the two pairs recognised by default and the fact that only the bring-your-own-loop path can reach it.

    A Safari bug the new browser suite found, and closed

    In framework mode — what every React / Vue / Svelte / Angular consumer runs — a streamed transcript settled a deterministic 31px short of the bottom on Safari and stayed there, so the last line of a reply sat under the fold. A timeline of a streamed turn showed the content settling in TWO layout passes (1118 → 1121 → 1152 px): scrollTop = scrollHeight ran against the middle one and nothing ran again afterwards. Auto-follow was armed the whole time — the viewport was not disarmed, it was satisfied, because “am I at the bottom?” is answered with a 50px tolerance that is right for keeping auto-follow armed and wrong as a definition of anchored.

    Fixed by a bounded re-check over the next few frames, which stops as soon as the gap is closed and re-reads the auto-follow flag every frame so a reader who scrolls away mid-settle is left alone. It exists only because a browser test drove a real progressive stream through a real engine; no unit test could see it, and the suite that shipped before this release delivered every reply atomically. every package

  • 7d6652a: A third cold audit, and the one CRITICAL it found

    Five auditors, five dimensions, no access to the changelog or the git history — because a previous round proved seven of the maintainer’s own claims false, and an auditor who reads the changelog is grading the essay rather than the code. One CRITICAL, twenty MAJOR. All twenty-one are closed here.

    The CRITICAL, and its family. <aparte-elicitation> registered its presenter on the config it could resolve at connectedCallback. All four wrappers call AparteChatHost.bind() — which runs attachConfig — from a post-mount hook, so the element connected before the boundary existed and registered on the global singleton. requestUserInput() then resolved the instance config, found no presenter, and returned {action:'cancel'}: the model was told the user refused a question the user was never shown. Silent, and in the supported multi-chat path.

    An earlier sweep for this bug class fixed every element that READS its config live and missed both that WRITE to it — a write has already happened, so resolving live cannot save it. attachConfig/detachConfig now notify the subtree, and a registrant implements AparteConfigAware.aparteConfigChanged(next, previous). Three MAJORs shared the root cause: <aparte-model-selector> cached its config (and its subscription) at connect; a segment renderer registered the documented way landed on the global and was invisible to any chat with a config prop — an instance config now inherits global registrations; and all four wrapper conversation-manager hooks wrote the manager to the global, making config + persistence a silently degraded mode. init(adapter, config?) on all four.

    The streaming seam lost text three ways. A non-streaming (string) reply skipped the parser flush, so a reply ending on a backtick or < lost that tail and one made only of those rendered nothing. The XML machine finalized after the parser flush, so the text it hands back — always a prefix of <artifact — reached a parser that would never be flushed again; the loss was total. And the adapter’s pre-tag path could add a segment but not update one that had just completed, freezing a code block mid-fence. The parity suite gained the two scenarios that missed all of this by a delta boundary, and it immediately rejected the core-side fix as well: it had split one sentence into two segments and put the held prefix before the prose it follows.

    Security. The artifact preview’s <meta> CSP was inserted relative to the first <head> the model’s markup declared — and a meta policy governs only what follows it, so a <script> placed before that tag ran uncontained. Reproduced in Firefox, WebKit and csp-attribute-less Chromium; since the csp iframe attribute is Chromium-only, this meta is the only containment those engines get. Three branches collapse to one: always first. AparteToolRenderer.render now returns string | HTMLElement like its sibling, and both it and the guide say that toolCall.input is model-chosen. And the primary backend-handler snippet no longer satisfies the mandatory authorize gate with Boolean(req.headers.get('cookie')), which authenticates nothing.

    Migration. getHostHandlers() returns Required<AparteHostHandlersConfig> — four fields, artifactRehydrate included. AparteToolRenderer.render widened, so existing string renderers keep working. The page-global config moved to a versioned Symbol.for key: two copies of @aparte/core on one page now get one global each instead of sharing an object across which instanceof is false. The shiki and marked peer ranges narrowed to the majors this repo tests against (^4 and ^18) — an over-narrow peer is a warning you can override, an over-wide one is a lie.

    Six of the twenty MAJORs were defects in the guards themselves, which is the part worth reading twice. Seven gate steps ran in no CI workflow, five of them guards that bite — and the only place their names appeared under .github/ was a comment narrating a previous audit finding the same thing. check:gate-in-ci now diffs the workflow against the gate chain. check-event-map was blind to object-shorthand detail, exempting the ten most important events. check-doc-snippets waived every diagnostic in a fence containing one unresolved name — 44 of 118 fences, hiding two for await SyntaxErrors in the branching guide. check-bundle-entries read a re-export shim and skipped the chunk where core lives, and could not have seen an inlined dependency at all, so it gained an assertion that core’s manifest declares none. check-export-mentions could not see type-only exports; measured once it could, 141 public exports were named on no page, now on a per-package ratchet that a new component cannot raise. @aparte/core, @aparte/engine, @aparte/plugin-marked, @aparte/plugin-model-selector, @aparte/plugin-shiki, @aparte/angular, @aparte/react, @aparte/svelte, @aparte/vue

  • d3e482c: The question panel: right chat, readable schema, replaceable field

    Eleven defects in the elicitation surface — the panel a tool puts up when it has to ask the user something. They survived four from-scratch audits for one reason, and it is the most useful thing in this release: no tool ever reached the model, so this surface was never executed. Not badly audited. Never run. Its unit tests pinned the shape it had and said nothing about which chat anything belonged to, what the model was asked to fill in, or what a screen reader would hear.

    One person with a local model broke it in four places in twenty minutes.

    Which chat a question belongs to. <aparte-elicitation> could mount its panel in ANOTHER chat’s composer: it walked up looking for one and fell back to document.querySelector. Removing that fallback was not enough — the walk itself reached <body>, where a querySelector searches the whole document, so it found the other chat’s composer by a longer route. The walk now stops at the chat boundary and finding nothing cancels with a warning that names the fix. A Stop in one chat also cancelled the question another was waiting on, telling that chat’s model the user had refused something they were still reading — the two window listeners had no instance filter at all. And in RAW core the composer could not identify itself either (all four wrappers set target; hand-written markup does not), so one chat’s Stop tore down the other’s open panel while its tool call kept waiting.

    What the model is asked to fill in. A question with no options was schema-VALID: options was neither required nor given a minItems, and the 2–6 range lived in the system prompt as prose. A local model duly sent two questions with no options, and the panel rendered a radio list whose only entry was “Other…” — a text box wearing the costume of a choice. options is now required with minItems: 2, and a model that ignores that gets an honest labelled text field instead of an empty enum. The question text also stopped being the object property KEY: two identically-worded questions used to collapse into one field, and the field was labelled only because the panel falls back to printing the key. Stable keys now, the text as the field’s title, and a label map so the model still reads “question → answer”.

    Who decides the UX. allow_other is out of the model’s schema and becomes setElicitationOptions({ allowOther }) on the config. The model describes the question; the host owns the surface. Default true, so nothing a user sees changes — only who gets to say so. A model still sending it is ignored, so no existing call breaks. A field of a schema you build yourself can still set allowOther, and it wins.

    What the user sees. The composer kept offering the attachment picker through an entire elicitation — there is nowhere for a file to go while you are answering a question. Declared now with data-panel-active + CSS instead of an inline style.display that clobbered a consumer’s own value. Groups of choices are named by the question they answer (role="radiogroup"/group + aria-labelledby): a screen reader used to announce “Chromium, radio button, 1 of 2” with no question attached. Seven strings that were hardcoded English — “Other…”, its placeholder and accessible name, “Skip”, “Yes”, “No”, “Your answer” — are optional locale keys with per-key fallback, plus the French. And the panel’s CSS moved out of a JS-injected <style> into the stylesheet with fifteen --aparte-elic-* tokens: it was the one surface that could not be themed, its variables were absent from the generated reference, and the injection was never re-created if anything removed it.

    How several questions are asked. A form of two or more questions put them all in one box — a shape inherited from MCP elicitation without being examined. MCP describes a form for collecting structured data; asking a person two different questions in the middle of a conversation is not that, and no product does it by stacking. Several questions are now asked ONE AT A TIME, with a chip per question that is also how you go back. Each field takes a short header for that chip (the tool schema asks the model for two or three words) and falls back to the question’s position rather than truncating a sentence. The protocol is untouched: the answer is still one object with every key, and the composer’s send button still means submit. layout: 'stacked' keeps the form case, which is real — it was just never the right default.

    The composer’s one button carries the progression: a chevron while questions remain, a check on the last one, and the panel is what knows which. That is why there is no “Next” button — the composer already has a button, in a place the user knows, and it already changes meaning between sending and stopping. Adding a second row for a Next made the panel taller and made it change height when that row went; folding the meaning into the existing button removed both problems and the button now says what it does.

    And the escape from the whole request sits in the panel’s CORNER, not in a row beside the button that advances through the form: adjacency promised “skip this question” while it declines everything. Position, not decoration — which is also why the reference implementations put theirs in a corner.

    What a consumer can replace. setElicitationFieldRenderer renders one field while the panel keeps everything around it. It returns a control rather than string | HTMLElement because a field must hand back a value, and the schema vocabulary is now a stated contract — three field kinds plus the object form, closed, with a test pinning the count so it cannot grow quietly.

    Migration. allow_other is ignored rather than rejected. The panel’s pixels can move if you were overriding its rules by selector — that is the trade for being able to override them by token. An <aparte-elicitation> mounted outside any chat now cancels with a warning instead of borrowing the first composer on the page. AparteLocale gains seven OPTIONAL keys, so an existing locale package keeps compiling and keeps rendering English.

    Twenty-six new unit tests and five browser tests — the first browser coverage this surface has ever had. Every fix has its sabotage, and one of them refuted a claim of mine before it shipped: the new axe scan does NOT catch an unnamed radio group, so the comment saying it would is corrected in place and the unit tests are named as the real guard. @aparte/core, @aparte/plugin-ask-user, @aparte/locale-fr

  • 1603015: No tool ever reached the model, and three smaller things a first test session found

    All four of these came out of one person sitting down with the examples and a local LM Studio, which found in twenty minutes what four from-scratch audits had not. The pattern is worth naming: an audit reads the code, a user runs it.

    A registered tool was never sent. AparteClient gates the request’s tools array on getCurrentModel()?.capabilities?.includes('function_calling'). Three facts made that gate permanently closed on the documented primary path: getCurrentModel() read provider.getModels() — the synchronous, hand-declared list — which every preset of @aparte/provider-openai-compat leaves empty because a compat endpoint’s list only exists after a GET /models; fetchModels() never wrote its result anywhere the resolver could see; and it declared only ['streaming']. So getTools() held the tool the app had registered and tools: [] went on the wire. The model then answered, correctly, that it had no such tool — which is exactly what a tester saw, with no error and no warning anywhere. The whole tools guide, needsApproval, human-in-the-loop approval and @aparte/plugin-ask-user were inert.

    Three changes, each with the reasoning where it lives. AparteConfig caches what refreshProviderModels() brings back and getCurrentModel() consults it before the static list. openai-compat declares function_calling, because a tools array is a property of the wire format it implements, not a guess about the model — /models returns {id, object, owned_by} and will never say otherwise, so waiting for it to declare the capability means never declaring it. And the gate now asks whether the model said it CANNOT rather than whether it said it can: a model that declares its capabilities and omits function calling is still honoured, but an unknown model — the common case — no longer turns an explicit registerTool into a silent no-op. Over-sending means a model that cannot call a tool does not call one; under-sending was silent and total. Two end-to-end tests that had been parked on this decision are now running.

    requireModelSelection is enforced by the thing that runs the turn. It was drawn by aparte-composer — greying itself, refusing submit() — and enforced nowhere else, so any other route to an aparte-send walked past it: a suggestion chip, a “try this prompt” button, a host dispatching the event itself. The turn then ran with config.defaultModel || '', an empty model id on the wire. Reported from an example, where the chips above the composer stay clickable while the composer is visibly greyed out waiting for its model list. The client now refuses such a send and says why, because the developer is who can fix it — an app that gates should disable its own affordances too.

    The model selector’s dropdown was ordered by a race. It fetches every provider’s /models in parallel and pushed each result as it arrived, so the order — and therefore what auto-select lands on — was decided by whichever endpoint answered first. A cloud provider on a CDN beats a local server that has to wake up, which means an app registering [local, local, cloud] could land on the paid one, and on a different one after a reload. The list is indexed by registration order now: auto-select documents itself as “the first model”, and first has to mean first.

    And the guide that described the old gate said tools are sent “only when the selected model’s capabilities include function_calling”, which was true and is the sentence that made the behaviour look intended rather than broken. @aparte/core, @aparte/provider-openai-compat, @aparte/plugin-model-selector

  • 950261d: The <artifact> XML streamer is a file, and its twin no longer disagrees with it

    @aparte/core and @aparte/engine each carry a hand-maintained copy of the same streaming <artifact> state machine — core cannot import engine’s, because engine peer-depends on core. Keeping two copies in step is the whole contract, and until now core’s half had no name: it was a private method plus a nested block inside a 2324-line class, so the two files cited each other by line number. Four of six of those citations had rotted onto unrelated code. :1658-1669, sold as “the finalize block”, was a tool handler’s AbortController; :1034-1042, sold as “_streamLoop’s leading writes”, was _handleSend resolving auth. One of the wrong ones was published in the API reference.

    Core’s half now lives in client/xml-artifact-feed.ts, holding both halves the way engine’s file does — feedXmlArtifactDelta and finalizeXmlArtifact. It moved without a semantic change: it dereferences this zero times, because the state it mutates was always owned by its caller. Every citation between the two files is now a name, and a new gate guard (check:cross-refs) refuses a comment that cites code by line number at all.

    Bug fixed, found by the pairing. A stream that ended on a held partial tag — … <arti, then nothing — silently dropped those characters. The feeder holds such a suffix on purpose (without it, a tag split across deltas loses the artifact’s whole lifecycle), and engine’s finalize() has always handed the held text back as chat text. Core’s finalize only ever handled the in-artifact case. Reachable with nothing unusual: any truncated reply whose last delta happens to end on <, <a, … <artifac.

    No API change: AparteClient behaves identically apart from that fix, and the new module’s exports are not re-exported from the package barrel. @aparte/core, @aparte/engine

  • c87d2b2: @aparte/plugin-ask-question is now @aparte/plugin-ask-user, and the tool is ask_user

    A rename, decided by looking at what the ecosystem actually calls this rather than at what we had called it.

    There are two naming levels and they answer differently. The protocol level has a standard — MCP calls it elicitation (elicitation/create), and ours already matched: requestUserInput, AparteElicitation*, <aparte-elicitation>. The tool level has no formal standard but a clear convention, and it is ask_user: Claude Code’s AskUserQuestion, datasette-agent’s ask_user(), pi-ask-user, ask-user-questions-mcp. ask_question was ours alone.

    What changed

    • the package: @aparte/plugin-ask-question@aparte/plugin-ask-user
    • the tool the model is offered: ask_questionask_user
    • the element alias: <aparte-ask-question><aparte-ask-user>, class AparteAskQuestionAparteAskUser
    • the exports: askQuestionTool/askQuestionHandler/setupAskQuestionaskUserTool/askUserHandler/setupAskUser, and AskQuestionOption/Item/DetailAskUser*

    What did NOT change, deliberately. The receipt keeps its names — questionReceiptRenderer, QuestionReceiptSegment, and the 'question-receipt' segment type. They name the ARTIFACT (a question and the answer it got, kept in the transcript), not the tool that produced it, and that segment type is a public string an app can emit on its own. Renaming it would break those apps for no gain.

    Migration. Change the dependency name, and the four identifiers above. No alias and no shim: this library is pre-1.0 and breaks cleanly rather than accumulating two names for one thing. The old package name stays on npm at its last published version and will receive nothing further — nothing is unpublished, so an existing install keeps working until it is updated.

    A model that keeps calling ask_question gets no tool by that name, which surfaces as an unknown-tool error rather than silence. @aparte/plugin-ask-user

Patch Changes

  • c87d2b2: @aparte/plugin-ask-question is now @aparte/plugin-ask-user, and the tool is ask_user

    A rename, decided by looking at what the ecosystem actually calls this rather than at what we had called it.

    There are two naming levels and they answer differently. The protocol level has a standard — MCP calls it elicitation (elicitation/create), and ours already matched: requestUserInput, AparteElicitation*, <aparte-elicitation>. The tool level has no formal standard but a clear convention, and it is ask_user: Claude Code’s AskUserQuestion, datasette-agent’s ask_user(), pi-ask-user, ask-user-questions-mcp. ask_question was ours alone.

    What changed

    • the package: @aparte/plugin-ask-question@aparte/plugin-ask-user
    • the tool the model is offered: ask_questionask_user
    • the element alias: <aparte-ask-question><aparte-ask-user>, class AparteAskQuestionAparteAskUser
    • the exports: askQuestionTool/askQuestionHandler/setupAskQuestionaskUserTool/askUserHandler/setupAskUser, and AskQuestionOption/Item/DetailAskUser*

    What did NOT change, deliberately. The receipt keeps its names — questionReceiptRenderer, QuestionReceiptSegment, and the 'question-receipt' segment type. They name the ARTIFACT (a question and the answer it got, kept in the transcript), not the tool that produced it, and that segment type is a public string an app can emit on its own. Renaming it would break those apps for no gain.

    Migration. Change the dependency name, and the four identifiers above. No alias and no shim: this library is pre-1.0 and breaks cleanly rather than accumulating two names for one thing. The old package name stays on npm at its last published version and will receive nothing further — nothing is unpublished, so an existing install keeps working until it is updated.

    A model that keeps calling ask_question gets no tool by that name, which surfaces as an unknown-tool error rather than silence. @aparte/core