AI-assisted chart authoring is a session, not a one-shot call. Users see suggestions, pick one, refine the audience, render, edit, replace, export — or abandon. Semiotic gives that arc structure with three composable surfaces:
- Conversation-arc telemetry — an opt-in event store recording the arc itself.
- Annotation provenance + lifecycle — every annotation can carry origin, confidence, and freshness.
- Variant discovery — an interface for proposing chart variants outside the hand-curated capability registry.
These ship together. Annotation freshness and the default visual treatment are live (the demo below uses them directly). The conversation-arc store, React hook, persistence sinks, and replay hydration helpers are functional. Variant discovery now includes the built-in heuristic proposer, evaluator, registration plug point, and MCP tool.
Conversation-arc telemetry
enableConversationArc() turns on a module-scoped ring buffer that records the arc of an AI-assisted session. Default surface is a no-op so the import is zero-cost when telemetry is off.
Interactive demo
Enable recording, fire some events, and watch the live store. Each click below callsstore.record(...). The event log is driven by a real subscriber on the real store — not a re-render of local state.
session: —0 events
Horizontal dot plot. Each dot is a recorded conversation-arc event placed at its timestamp on the x-axis and its event type on the y-axis. Colors match the event-type buttons above.
Live summary · summarizeArc(history)
suggestion-shown · 0suggestion-chosen · 0audience-set · 0chart-rendered · 0chart-edited · 0chart-replaced · 0chart-exported · 0chart-abandoned · 0interrogation-asked · 0interrogation-answered · 0nav-node-focused · 0nav-branch-expanded · 0annotation-status-changed · 0
Recording is off. Click ‘Enable recording’ to start a session.
Persistence + replay
Registering a sink makes accepted events durable without changing the recording API. The demo below writes a sample arc to localStorage, clears only the live buffer, replays the local artifact, and can hydrate the same inspector from a static JSON fixture atdocs/public/data/conversation-arc-replay.json. Replay uses loadConversationArc(), so it updates the visible store snapshot without re-firing sinks or analytics subscribers.
session
none
active store id
localStorage
0
semiotic-docs:conversation-arc
fixture
0
docs/public/data/conversation-arc-replay.json
Live summary · summarizeArc(history)
suggestion-shown · 0suggestion-chosen · 0audience-set · 0chart-rendered · 0chart-edited · 0chart-replaced · 0chart-exported · 0chart-abandoned · 0interrogation-asked · 0interrogation-answered · 0nav-node-focused · 0nav-branch-expanded · 0annotation-status-changed · 0
Persisted summary · summarizeArc(localStorage)
suggestion-shown · 0suggestion-chosen · 0audience-set · 0chart-rendered · 0chart-edited · 0chart-replaced · 0chart-exported · 0chart-abandoned · 0interrogation-asked · 0interrogation-answered · 0nav-node-focused · 0nav-branch-expanded · 0annotation-status-changed · 0
Fixture summary · summarizeArc(JSON artifact)
suggestion-shown · 0suggestion-chosen · 0audience-set · 0chart-rendered · 0chart-edited · 0chart-replaced · 0chart-exported · 0chart-abandoned · 0interrogation-asked · 0interrogation-answered · 0nav-node-focused · 0nav-branch-expanded · 0annotation-status-changed · 0
No persistence sink registered yet.
The replayed or recorded arc will appear here.
Wiring — React
The useConversationArc hook is the React entry point. It auto-enables the module-scoped store on mount, subscribes via useSyncExternalStore for tear-free re-renders, and returns a stable record callback plus a livesummary reduction:
JSX
import { useConversationArc } from "semiotic/ai" function ArcInspector() { const { history, summary, enabled, sessionId, record, clear } = useConversationArc() return ( <> <header> Session {sessionId} · {summary.total} events · {summary.byType["chart-exported"] ?? 0} exports </header> <button onClick={() => record({ type: "chart-exported", component: "LineChart", format: "jsx" }) }>Mark export</button> <EventList events={history} /> </> ) }
Wiring — non-React sinks
For analytics sinks, replay fixtures, or anything outside the React tree, use the module-scoped store directly. The hook is just sugar over the same store.
JSX
import { createLocalStorageConversationArcSink, createWebhookConversationArcSink, enableConversationArc, getConversationArcStore, loadConversationArc, registerConversationArcSink, } from "semiotic/ai" enableConversationArc({ capacity: 1000, sessionId: "session-abc" }) const localSink = createLocalStorageConversationArcSink({ key: "my-app:conversation-arc", }) const unregisterLocal = registerConversationArcSink(localSink) registerConversationArcSink(createWebhookConversationArcSink({ url: "/analytics/conversation-arc", headers: { "X-App": "viz-builder" }, })) const store = getConversationArcStore() const unsubscribe = store.subscribe((event) => { // In-process observer: update an inspector, console, or live dashboard. console.log(event.type, event) }) // Later: hydrate an inspector from the durable local artifact. const savedEvents = localSink.load() loadConversationArc(savedEvents, { enabled: false }) // Teardown when the app owns this lifecycle. unsubscribe() unregisterLocal()
Auto-instrumentation
Two surfaces wire themselves to the arc store automatically:
useChartSuggestions emits suggestion-shown whenever the suggestion list changes (deduplicated by component-list signature).useChartInterrogation emits interrogation-asked onask() and interrogation-answered when the response (or error) returns — with measured latencyMs.AccessibleNavTree emits nav-node-focused andnav-branch-expanded as a reader traverses thenavigation tree — the arc's firstreception-side signal, correlated by the tree's chartId.
All are zero-overhead when the arc store is disabled (the default). For audience-picker UIs, call recordAudienceChange(next, previous) in the picker'sonChange handler.
Try it: change the intent or audience below. No record() calls in the demo's own component code — only useChartSuggestions running andrecordAudienceChange on the picker. Watch the event log fill in.
Audience picker (recordAudienceChange)
Top suggestion
BarChartscore 1.0also:DotPlot, PieChart, semiotic.recipe.waffle.v0
Latest arc events (auto-fired)
Events will appear here as you change intent or audience.
Event vocabulary
Thirteen variants in a discriminated union: suggestion-shown,suggestion-chosen, audience-set, chart-rendered,chart-edited, chart-replaced, chart-exported,chart-abandoned, interrogation-asked,interrogation-answered, and the reception pairnav-node-focused / nav-branch-expanded, plusannotation-status-changed. Each carries the fields a downstream analytics or replay system would actually consume (component name, rank, format, reason; node id, role, and level for the nav events; annotation id and status transition for contested notes). ThearcId field threads multiple events into a single named arc when you need it.
Annotation provenance + lifecycle
Anchored conversations stay defensible when every annotation knows where it came from and when it should be considered stale. Two optional blocks attach to any annotation:provenance ( author, source, confidence,createdAt, stableId) and lifecycle (freshness, ttlHint, anchor).computeAnnotationFreshness classifies each annotation into a band;applyAnnotationLifecycle additionally applies a default visual treatment (opacity for aging, dashing for stale, hiding for expired).
Lifecycle scrubber
The chart below carries two annotations — one from a user with a 30-day TTL, one from an AI with a 14-day TTL and lower confidence. Drag the slider to advance "now" and watch them drift through fresh → aging → stale → expired:
Hand-placed spikefresh
by alice · 22 days old · TTL P30D
AI anomaly tagfresh
by model-v3 · 0 days old · TTL P14D
Drag the slider forward in time. Annotations dim through fresh → aging → stale, take on a dashed border at stale, then disappear once they hitexpired. The chart is callingapplyAnnotationLifecycle(annotations, { now }) directly — the shipping helper handles freshness, opacity, dashing, and the expired filter; passshowExpiredAnnotations: true to keep expired annotations visible.
Attaching provenance
TS
import { withProvenance } from "semiotic/ai" const ann = withProvenance( { type: "y-threshold", value: 100, label: "SLA breach" }, { provenance: { author: "alice", source: "user", createdAt: "2026-05-20T14:00:00Z", stableId: "annot-sla-2026q2", }, lifecycle: { ttlHint: "P30D", anchor: "semantic" }, }, )
The anchor mode matters when data refreshes. "fixed" keeps the recorded coordinate verbatim; "latest" re-pins to the most recent data point;"sticky" rides forward until removed (the existing streaming behavior);"semantic" re-resolves via stableId when new data arrives, falling back to the recorded coordinate when the target is gone.
Variant discovery
Hand-curated capability.variants are bounded by what humans wrote. Variant discovery is the API surface for proposing configurations the registry doesn't include — from heuristic walkers, LLM agents, or future ML models — and scoring them with the same rubric the built-in suggester uses.
TS
import { proposeVariant, evaluateVariantProposal, registerVariantDiscovery, type VariantProposal, } from "semiotic/ai" // A bespoke discovery function: propose a streamgraph when the user // chose a multi-series area but their audience profile rewards trend // over part-to-whole. registerVariantDiscovery((component, capability, context) => { if (component !== "StackedAreaChart") return [] if (context.audience?.targets?.["trend"] === undefined) return [] return [ { id: "StackedAreaChart:streamgraph", baseComponent: "StackedAreaChart", intentDeltas: { trend: 1, "part-to-whole": -1 }, buildProps: (profile) => ({ baseline: "wiggle", stackOrder: "insideOut", }), rationale: "Streamgraph reveals the trend better for this audience.", source: "heuristic", }, ] }) const proposals = proposeVariant("StackedAreaChart", capability, ctx) const scores = proposals.map((p) => evaluateVariantProposal(p, profile, ctx.audience, { intent: ["trend", "composition-over-time"], baselineComponent: "StackedAreaChart", }) )
proposeVariant now emits registered capability variants, conservative heuristic variants, and same-intent cross-family alternatives.evaluateVariantProposal scores each proposal against fit, intent, rubric deltas, novelty, risk, and audience bias. Agents can call the same path through the MCPproposeChartVariants tool.
Why these three together
The arc records what happened. The annotations preserve what the user said about it. Variant discovery keeps the system honest about what it doesn't yet know — and where the learning slots in.