Skip to content

Scenario provider — a scripted model for demos and tests

@aparte/provider-scenario is an AI provider that never calls a model: it replays turns you wrote. Text streams at a typing pace, a thinking block appears, a tool is called and the real loop runs your handler, an error fails the turn — the whole UI behaves as it would with a model, and behaves the same every time.

Two reasons to want that. A demo — a landing page, a docs frame, a storybook — that streams something real without a backend or a key. And your tests: a chat wired to this provider is deterministic end to end, which is what a test of your own app around aparté needs and what a network mock cannot give you as simply.

Terminal window
npm install @aparte/provider-scenario @aparte/core

@aparte/core is the only peer dependency; the package has none of its own.

import { aparteGlobalConfig, AparteClient, AparteDirectTransport } from '@aparte/core';
import '@aparte/core/styles.css';
import { createScenarioProvider } from '@aparte/provider-scenario';
import { setupMarkedProvider } from '@aparte/plugin-marked';
setupMarkedProvider(); // what renders the **markdown** below — without it, text streams plain
aparteGlobalConfig.registerAIProvider(createScenarioProvider({
turns: [
'Hello! Ask me anything.',
[{ thinking: 'Let me see…' }, { text: 'Here is my **answer**, with *markdown*.' }],
],
}));
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
new AparteClient().start(); // streams the reply AND echoes the user's message

That is the whole file, next to an <aparte-chat> on the page. Two things it does NOT need: an aparte-send handler — the client echoes the user’s message itself (echoUserMessage: false if your host owns its transcript) — and any markdown wiring beyond the one setup call above (streaming-markdown is the mid-stream alternative).

turns answers the model’s calls in order and repeats the last one. Every call advances — a retry, and the second half of a tool round-trip, included. It is the form for a demo that always goes the same way, or a test that needs exactly three replies.

A turn is a string (one text step) or a list of steps:

StepWhat it does
{ text }Streams the text, chunk by chunk. Rendered as markdown once a markdown plugin is installed (@aparte/plugin-marked or streaming-markdown) — plain text otherwise, **stars** included. An <artifact> tag becomes a card once @aparte/plugin-artifacts is set up (it is prose otherwise).
{ thinking }Streams reasoning — the thinking block.
{ tool, input?, id? }Calls the tool of that name. The loop runs the handler you registered and calls the provider again with the result. In scenarios mode, pair it with a scenario declaring after: '<tool>' — without one, the result re-matches the same when and the conversation loops until the client’s maxTurns stops it (the provider warns at creation).
{ error }Fails the turn with that message — what a provider error looks like to the UI.
{ wait }Pauses for that many milliseconds — a slow model, a long tool.
{ usage }Overrides the usage reported at the end (merged over the estimate).
createScenarioProvider({
scenarios: {
default: 'Ask me for a haiku, or the weather.',
haiku: { when: /haiku/i, turn: 'Web components hum — \nno framework in the wind, \njust the page, alive.' },
weather: { when: 'weather', turn: [{ text: 'Let me check.' }, { tool: 'get_weather', input: { city: 'Lille' } }] },
forecast: { after: 'get_weather', turn: 'Cloudy, 14 °C. Bring a jacket.' },
},
});

The rule that picks a scenario for each call: a tool result goes to the scenario that declared after for that tool; otherwise the first scenario whose when matches the last user message (a string is a case-insensitive substring, a RegExp is tested as is); otherwise default; otherwise the first one declared. A bare string or step list is a scenario with no condition. Pass match(request, scenarios) to pick by a rule of your own — return undefined to fall back to the default rule.

The get_weather above only does something if the app registered a tool of that name (aparteGlobalConfig.registerTool); an unregistered tool fails the call the way it would with a real model, which is also a scenario worth having.

match returns the scenario’s key, not the scenario — the second argument hands you the objects, so returning one is an easy slip. When no scenario is found for a call the provider streams an empty turn and says so in the console, naming what match() returned and the keys it knows.

after: routes on which tool ran, not on what it returned. To pick the next scenario from the value — the option the user chose in an ask_user question, the branch of a wizard — let the tool’s handler put the answer in its result and read it back in match, from the last tool_result message. The provider stays stateless; the conversation carries the state:

createScenarioProvider({
scenarios: {
start: { turn: [{ tool: 'ask_user', input: { question: 'Where do you run?', options: [{ title: 'browser' }, { title: 'server' }] } }] },
browser: { turn: 'In the browser, then: the direct transport, your key stays local.' },
server: { turn: 'On a server: the backend transport, the key never leaves it.' },
},
match: (request, scenarios) => {
const last = [...request.messages].reverse().find((m) => m.role === 'tool_result');
if (!last) return 'start';
const picked = String(last.content).trim(); // "browser" or "server" — the tool's own result
return picked in scenarios ? picked : undefined; // undefined → the default rule
},
});

ask_user’s result is the chosen label as prose (and, since 0.16, the same answer as a value on the segment), so the handler needs no encoding for a single choice; a handler of your own can write whatever its match reads back — content: 'mode=browser' is a perfectly good contract between the two.

pacing: { chunk: 12, delay: 24 } (the defaults) streams twelve characters every 24 ms; pacing: 'instant' gives a test the whole reply at once. Every turn ends with a done carrying an estimated usage — four characters per token — so a context gauge moves; a { usage } step overrides it.

The provider is local and keyless (isLocal: true, an empty config schema) and offers one model, scripted, declaring streaming and function_calling, so the model selector and the tool gate work unchanged; pass models to offer others.

import { aparteGlobalConfig } from '@aparte/core';
import { createScenarioProvider, showcase } from '@aparte/provider-scenario';
aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase }));

showcase answers haiku, table, code, weather (a tool round-trip), ask me a question (an ask_user call, answered by @aparte/plugin-ask-user’s panel), survey (two questions in one call — the panel’s stepper), artifact (a card with the artifacts plugin set up), slow and fail — the surface a chat has, in one registration. It is what the docs’ live frames run on.

This repository’s own browser suite keeps its network mock: that suite tests the real wire path — provider format, transport, auth — which this provider bypasses by construction. Use the scenario provider to test your app around the chat, not to test aparté’s adapters.

The same line holds for a provider you wrote. The scenario stands in for the whole provider: everything above it — the loop, tool execution and approval, the transcript, your renderers — runs for real, and nothing below it runs at all. An app whose provider is the thing under test (a worker, a wire format, a tool-call parser, an executor queue) gets no coverage of that layer from a scenario; keep a fake of your own for it, and use the scenario for what sits above.