@octabits-io/agent-ui
The UI half of agents propose, humans decide: the state machines behind an
AI-workflow surface — the poll-driven run, the duplicate-safe trigger,
cross-page progress, and the headless review of a Proposal — plus a
binding for Vue and a binding for React, and a review card in each.
One capability, one package; a subpath is what you already run.
import { createProposalReviewStore } from '@octabits-io/agent-ui'; // any framework
import { useProposalReview } from '@octabits-io/agent-ui/vue'; // Vue
import { useProposalReview } from '@octabits-io/agent-ui/react'; // React
| Subpath | Peers | What it is |
|---|---|---|
@octabits-io/agent-ui |
@octabits-io/proposal (zod through it) |
The framework-free state machines, as observables (get() + subscribe()) with pure derivations. The decision semantics live here, once. |
@octabits-io/agent-ui/vue |
vue, @octabits-io/events (the stream and live-resource composables) |
The headless composables: each mirrors one observable into Vue reactivity and hands the actions through. No markup, no design system. |
@octabits-io/agent-ui/vue/plain |
vue |
RunGraph in plain SVG as a render function — built, no SFC, no @nuxt/ui, no stylesheet; the twin of ./react/plain’s. |
@octabits-io/agent-ui/nuxt-ui/*.vue |
@nuxt/ui, vue-i18n |
ProposalReviewCard, AiResultReviewCard, AiButton — the reference styled surfaces on Nuxt UI, as source the consumer’s Vite compiles. |
@octabits-io/agent-ui/vite |
vite |
nuxtUiVueMode() — the one line a plain Vite + Vue app adds so those cards build without Nuxt. |
@octabits-io/agent-ui/styles.css |
(Tailwind v4) | @source registration for the source-shipped cards. |
@octabits-io/agent-ui/react |
react, @octabits-io/events (the stream and live-resource hooks) |
The headless hooks, name for name with the composables, over useSyncExternalStore. |
@octabits-io/agent-ui/react/plain |
react |
ProposalReviewCard as semantic HTML with a data-slot on every part and a classNames map. No stylesheet, no component library. |
Every peer is optional except the two contracts, @octabits-io/proposal and
@octabits-io/events — peers with wide ranges, so your app holds one copy of
each: a React app installs no vue, a Vue app on Vuetify installs no
@nuxt/ui, and the root is usable from Svelte, Solid or plain TypeScript — an
observable is get() + subscribe().
The root is lint-fenced to import
@octabits-io/proposal and nothing further.
When not to use it
- You want a finished look. The plain React card is semantic HTML with
data-slots and no stylesheet; the Nuxt UI cards need@nuxt/ui,vue-i18nand Tailwind v4. On another design system, takeuseProposalReviewand bring your own markup — the same rows, the same decision out, because the semantics are in the root and not in a template. - You are looking for the transport. Every store and every hook takes its
I/O as an option —
pollFn,checkFn,fetchWorkflowStatus,onApply— and nothing here calls a route of its own. The client, the endpoints and the posting of a decision are the app’s. - You want the app’s state management.
createStoreis the smallest observable these machines need, not a store for your app. Pinia, Zustand and friends stay; a binding mirrors one of these objects into them. - You want the dialog or the floating panel. What surrounds a card — where
the review opens, how it follows the user across pages, how it hooks into the
router — stays in the app: thin views over
createAiProgressCoreand the registry, coupled to a router this package does not know. - The result is a value, not a proposal.
createProposalReviewStorereviews aProposal. A workflow whose output is a text to read has nothing to accept or reject: poll it withcreateWorkflowPollerand show the output. - The component renders on the server. There is nothing to subscribe to in a React server component. Keep the review in a client component and hand it the proposal as a prop.
- You need the session or the bell. That is
@octabits-io/sessionand@octabits-io/activity; this package is the AI-workflow UX only. - You need the event stream itself. The browser side of a server-sent event
stream is
@octabits-io/events/client(createEventStreamClient,createSseFrameParser);useEventStreamanduseLiveResourcein the two bindings are the thin wrappers over it.
The root: the state machines
| Export | What it is |
|---|---|
createProposalReviewStore(proposal, options?) |
The headless review of a Proposal: which operations the reviewer keeps, what they edited the values to, the open variant tab. view() derives the rows a surface renders (label as { key } or { text } — translating is the binding’s job — control, value, current-value preview), counts, dangling children, canApply; toDecision() is the ProposalDecision to post. deriveProposalReview and decisionOf are the same as pure functions over the state; proposedValueOf (the reviewer-editable value of an operation) and previewValue (a value as one readable line, the host’s formatValue first) are the row helpers they use. |
createWorkflowPoller(options?) |
Poll-driven workflow state over an injected pollFn, until a terminal status; deriveWorkflowView turns the record into status/progress/isCompleted/…. isTerminalStatus / isActiveStatus are the predicates over AiWorkflowStatus it polls by. |
drawRunGraph(input) |
What a renderer turns into markup: deriveRunGraph’s layout plus, per node, its label shortened to fit, its status or detail on one or two lines, its mark, tooltip, accessible name and text baselines, and per edge its SVG path. Both plain RunGraphs are this mapped onto elements; a Svelte or Solid one would be too. runGraphStatusLabelsEn and runGraphActorLabelsEn are its English defaults. |
deriveRunGraph(input, layout?) |
A run laid out as a graph: each step one column after its furthest dependency, the person’s decision on the run’s proposal as a node of its own (review: { state: 'pending' | 'waiting' | 'applied' | 'dismissed' | 'reverted' }), a revert as one more node after it. Takes the steps as the poller holds them (key, status, dependencies — octaflow’s public view) and returns nodes with coordinates, edges in their state (done, active, waiting, pending), counts per status, and warnings for unknown dependencies or cycles instead of failing. A step may name its actor (system, model, person — the review and the revert are a person’s), carry a detail (“2 fields edited · signed”), and be waiting itself, so a process with more than one person in it — two signatures, a confirmation — is one graph. Pure; any renderer can draw it. |
createWorkflowGuard(poller, { checkFn, pollFn }) |
Rehydration of a run that is already going, and a trigger that refuses to start a duplicate. |
createAiProgressStore(options) |
Cross-page tracking of triggered workflows with completionSignal / appliedSignal counters pages watch to refresh. activeWorkflows, hasActiveWorkflow and trackedByEntityRef are the pure selectors over its state. |
deriveAiCardState(input) |
The idle / active / failed phase of a trigger card. |
createActiveWorkflowProbe(options) |
“Is something already running for this entity?” — polls while active. |
createWorkflowRegistry() |
Registration + label lookup over the app’s own workflow definitions. |
reviewMessageTemplatesEn |
The English strings a review surface renders, as one flat record of {name} templates (ReviewMessageTemplates is the type to translate). Both bindings derive theirs from it — ./vue nests it as ai.review.*, ./react/plain turns the parameterised ones into functions with formatMessageTemplate (a placeholder with no value is left as written, so a missing parameter is visible). |
createStore(initial) |
The primitive everything above is built on (Observable / Store are its types). |
createPausableInterval(fn, ms, onActiveChange?) |
The timer the poller and the probe run on: pause() / resume() / isActive(), no reactivity of its own, not auto-disposed — a caller pauses it in its own teardown. |
The review store
The decision semantics live here, once, so every surface agrees on them:
- Selection defaults to accepted — the reviewer confirms a set already filtered down to real changes.
- Every accepted
update/createcarries its value inedits, changed or not: the host applies what the reviewer saw. - All four operation kinds are rows. A review that silently omits the creates is worse than no review.
- A child kept while its parent was rejected is surfaced as
dangling. - Un-varianted operations appear under every variant tab.
const review = createProposalReviewStore(proposal, {
variantLabel: (v) => v.toUpperCase(),
formatValue: (value, op) => (op.display?.control === 'richtext' ? toPlainText(value) : null),
});
review.toggle(rowId, false); // keep / reject
review.setValue(rowId, 'edited copy'); // update / create values only
review.setVariant('en');
review.subscribe(() => render(review.view()));
post('/apply', review.toDecision());
Transport is not here: a decision is emitted and the host posts it.
The decision review store
The other thing a person reviews: a decision a decider was unsure of — one option
per question, with its confidence. createDecisionReviewStore takes octaflow’s
decision output with the step’s questions as it is (the shape is structural;
this package does not import octaflow), and owns the rules:
- The choice opens on the decider’s answer — checking a suggestion should be one click in the common case.
toAnswers()answers every question, changed or not.- A choice outside a question’s options is refused: the person is held to the options the decider was.
- The view marks the questions the decider was unsure of (
unsure) and the ones the person changed (changed), and carriescalibratedso a card can say a model’s stated confidence is a statement, not a probability.
const review = createDecisionReviewStore(
{ ...decision, questions, threshold }, // octaflow's DecisionOutput + the step's questions
{ optionLabel: (question, option) => t(`options.${question}.${option}`) },
);
review.choose('topic', 'question');
post(`/api/messages/${id}/triage`, review.toAnswers()); // the reviewer is the session's user, server side
The strings are decisionMessageTemplatesEn (ai.decision.* in the Vue
fragment). Bindings: useDecisionReview in ./vue and ./react; cards:
DecisionReviewCard.vue under ./nuxt-ui/*, DecisionReviewCard in
./react/plain, and the decision-review-card shadcn registry item.
Binding a third framework
// Vue (what ./vue does)
const state = shallowRef(store.get());
store.subscribe((next) => { state.value = next; });
// React (what ./react does)
const state = useSyncExternalStore(store.subscribe, store.get);
./vue — the composables
useProposalReview(proposal, options?)— the headless review:rows(one per operation, with label / control / value / current-value preview),counts, a two-wayactiveVariantfor a tab control,dangling,canApply, andtoggle/setValue/toDecision(). This is whatProposalReviewCard.vueis built on; a card on Vuetify or PrimeVue is this composable plus your own markup, and it emits the same decision.useDecisionReview(decision, options?)— the headless review of a decision a decider was unsure of:questions(options, the decider’s choice, its confidence and reason,unsure,changed),canConfirm,choose,resetandtoAnswers().DecisionReviewCard.vueis built on it.useAiWorkflow(options?)/useAiWorkflowGuard(options)— poll-driven workflow state over an injectedpollFn; the guard adds mount-time rehydration and a duplicate-safetrigger.createAiProgressCore(options)— the setup body of the app’s own progress store (Pinia or otherwise): cross-page tracking withcompletionSignal/appliedSignal.useAiCardState(store, entityRef),useActiveAiWorkflowProbe(options),useEventStream(options),useLiveResource(keys, refresh),useObservable(observable).agentUiMessagesEn— English defaults for everyai.review.*andai.decision.*key the cards read; deep-merge it under your own messages. The type is exhaustive on purpose, and a test derives the key set from the SFC sources. The strings are the root’sreviewMessageTemplatesEnnested underai.review— the same record the React card’s messages come from.
The root’s state machines are re-exported from ./vue (and from ./react), so
one import serves both halves.
./vue/plain — the run graph in plain SVG
RunGraph for a Vue app on any design system, or none: a render function that
ships built, so it needs no @nuxt/ui and no SFC compiler setup. It draws from
the root’s drawRunGraph, as ./react/plain’s RunGraph does, so the two
render the same elements, data-* attributes and text; the props are the
same, and the callback is an event:
<script setup lang="ts">
import { RunGraph } from '@octabits-io/agent-ui/vue/plain'
</script>
<template>
<RunGraph
:steps="workflow.steps"
:review="{ state: 'waiting', label: 'Your review' }"
:status-label="(status) => t(`run.status.${status}`)"
title="Research run 12"
@select-node="scrollToStep"
/>
</template>
Listening for select-node is what makes the nodes focusable buttons (click,
Enter, Space); with no listener they stay inert. Styling, classNames,
actor and detail work as described under the run graph.
./nuxt-ui/* — the Nuxt UI cards
// app/components/AppProposalReviewCard.ts — register under your own name
export { default } from '@octabits-io/agent-ui/nuxt-ui/ProposalReviewCard.vue'
<AppProposalReviewCard
:proposal="proposal"
:applying="applying"
:variant-label="(v) => localeNames[v] ?? v"
:format-value="(value, op) => (op.display?.control === 'richtext' ? toPlainText(value) : null)"
@apply="(decision) => post(`/api/ai/workflows/${id}/apply`, decision)"
@dismiss="dismiss"
>
<template #richtext="{ value, setValue }">
<MyEditor :model-value="value" @update:model-value="setValue" />
</template>
</AppProposalReviewCard>
The cards use explicit imports (@nuxt/ui/components/*.vue, vue-i18n), so no
auto-import configuration is required, and they run wherever Nuxt UI runs — a
Nuxt app, or a plain Vite + Vue app with @nuxt/ui/vite (see apps/fixture-vue in
the repository for the latter, end to end).
Plain Vue (no Nuxt) needs one line. Nuxt UI ships two Icons: the Nuxt one
under components/ (renders through @nuxt/icon, which needs Nuxt’s
#imports) and a Vue-mode one under runtime/vue/components/ (renders through
@iconify/vue and the bundled collections). Its Vite plugin swaps the Vue-mode
one in for relative imports inside its own runtime and for auto-imported
components — but not for a bare import UIcon from '@nuxt/ui/components/Icon.vue',
which is what these cards (and any explicit-import consumer) write. Without a
rewrite the build fails with useAsyncData is not exported by …/stubs/vue-router.js
(Nuxt UI 4.11). @octabits-io/agent-ui/vite applies Nuxt UI’s own rule to bare
imports; it is a no-op the day Nuxt UI does it itself:
// vite.config.ts
import ui from '@nuxt/ui/vite'
import { nuxtUiVueMode } from '@octabits-io/agent-ui/vite'
export default defineConfig({ plugins: [nuxtUiVueMode(), vue(), ui()] })
(router: 'inertia' | 'none' picks the matching Link when ui() is not on
vue-router.) A Nuxt app needs nothing: the module resolves #imports for real.
apps/fixture-vue in the repository is the reference, driven end to end by
apps/e2e.
Tailwind (required): the cards live in node_modules, which Tailwind v4’s
source detection skips. Import the stylesheet, which registers them via @source:
@import "tailwindcss";
@import "@nuxt/ui";
@import "@octabits-io/agent-ui/styles.css";
Icons: the cards use i-lucide-sparkles, i-lucide-triangle-alert and a
few more from the lucide collection. In a plain Vue app, bundle them —
ui({ icon: { clientBundle: { icons: [...] } } }) with @iconify-json/lucide
installed — or @nuxt/icon resolves them at request time from the Iconify API,
which is a third party your data-protection story did not mention.
The SFCs self-reference @octabits-io/agent-ui/vue so module-scoped state
stays single-instance — they resolve through dist. Build the package before
running the SFC tests or a consumer’s vue-tsc.
./react — the hooks
useProposalReview(proposal, options?)— the same review, hook for composable. A card on shadcn, MUI or Chakra is this hook plus your JSX.useAiWorkflow(options?)/useAiWorkflowGuard(options)— give the component akeyper entity so a new entity gets a fresh guard.useAiProgress(store)— subscribes a component to the app’s onecreateAiProgressStore(options)(from the root; a module singleton or a context value).useAiCardState(progress, entityRef, hasActiveWorkflow?),useActiveAiWorkflowProbe(options),useEventStream(options),useLiveResource(keys, refresh),useObservable(observable).
The core objects are created once per component instance and own their timers;
callbacks in the options are read at first render, the same rule the Vue
composables follow. useEventStream and useLiveResource are the exceptions:
their handlers go through a ref, so a closure over render-time values stays
current without the connection or the registration being rebuilt.
./react/plain — the plain card
<ProposalReviewCard
proposal={proposal}
applying={applying}
translate={(key) => t(key)} // operation labels that arrive as i18n keys
messages={reviewMessagesDe} // optional; English by default
renderRichText={({ value, setValue, disabled }) => <MyEditor value={value} onChange={setValue} disabled={disabled} />}
classNames={{ card: 'rounded-lg border', apply: 'btn btn-primary' }}
onApply={(decision) => post(`/api/ai/workflows/${id}/apply`, decision)}
onDismiss={dismiss}
/>
<!-- the decision card: octaflow's decision output with its questions -->
<AppDecisionReviewCard
:decision="{ ...decision, questions }"
:option-label="(question, option) => t(`options.${question}.${option}`)"
:confirming="posting"
@confirm="(answers) => post(`/api/messages/${id}/triage`, answers)"
/>
Every element carries data-slot (card, header, row, checkbox,
textarea, apply, …), so a stylesheet can address the whole card without a
class-name map — apps/fixture-react does exactly that in
a few dozen lines of CSS. reviewMessagesEn is the English string set and
ReviewMessages the type to translate; the English itself is the root’s
reviewMessageTemplatesEn, and reviewMessagesFromTemplates(record) turns a
translated copy of it into this card’s shape — one translation, both bindings.
A vendorable shadcn version of the same card is in the repository’s registry/
(npx shadcn add), over the same hook.
DecisionReviewCard is the decision card in the same style — a fieldset of
radios per question, data-slot on every part, messages as a
DecisionMessageTemplates record:
<DecisionReviewCard
decision={{ ...decision, questions, threshold }}
optionLabel={(question, option) => t(`options.${question}.${option}`)}
confirming={posting}
onConfirm={(answers) => post(`/api/triage/${id}/decide`, { answers })}
/>
The run graph
<RunGraph
steps={workflow.steps} // AiWorkflowStepData[]: key, status, dependencies
review={{ state: 'waiting', label: 'Your review' }} // the decision as a node; omit for a run that proposes nothing
statusLabel={(status) => t(`run.status.${status}`)} // English by default (runGraphStatusLabelsEn)
title="Research run 12"
onSelectNode={(key) => scrollToStep(key)} // optional: nodes become focusable buttons
/>
deriveRunGraph’s layout drawn as SVG, with no colours of its own: outlines in
currentColor, dashed while a node or edge has not started, so it reads in black and
white. Nodes carry data-slot="node" with data-kind (step, review, revert) and
data-status; edges carry data-state. A host colours it with selectors —
[data-slot=node][data-status=waiting] { color: … } — or classNames. The SVG has a
<title> and a <desc> summarising the counts; each node has a tooltip with its full
label. apps/demo-approval shows it on every run page.
Give the steps an actor and each node carries data-actor and a small mark above its
label — who does the step (actorLabel, English by default: runGraphActorLabelsEn) —
so a reader sees where a model worked and where a person stands between it and the
records. A step’s detail stands in for its status line and may wrap onto a second
line. The approval demo draws a whole request this way: the vendor check, the draft
run’s own steps, both officers’ signatures and the decision:
<RunGraph
steps={[
{ key: 'draft', label: 'Draft', status: 'completed', actor: 'model' },
{ key: 'dpo', label: 'DPO signs', status: 'waiting', actor: 'person', dependencies: ['draft'], detail: 'Reviews the draft' },
{ key: 'iso', label: 'ISB signs', status: 'completed', actor: 'person', dependencies: ['draft'], detail: 'Signed by J. Kaya' },
{ key: 'decision', label: 'Decision', status: 'pending', actor: 'system', dependencies: ['dpo', 'iso'] },
]}
actorLabel={(actor) => t(`actors.${actor}`)}
/>
Wiring example
// stores/aiProgress.ts — transport injected, signals consumed by pages
export const useAiProgressStore = defineStore('ai-progress', () =>
createAiProgressCore<AiDialogRequest>({
fetchWorkflowStatus: async (id) => {
const { data, error } = await call(api.ai.workflows[':id'].status.$get({ param: { id: String(id) } }))
return error ? null : data
},
}))
// a component — rehydrate on mount, refuse duplicate triggers, review the outcome
const guard = useAiWorkflowGuard<MyOutput>({ checkFn: fetchLatest, pollFn: fetchLatest })
await guard.trigger(() => api.ai.workflows.$post({ json: { type: 'contact-brief', contactId } }))
const proposal = computed(() => guard.output.value?.propose ?? null)
What is tested
src/core/proposalReview.test.ts is the decision-semantics suite — every
accepted value in the decision, changed or not; all four operation kinds;
variants and dangling children. src/vue/*.test.ts and
src/react/hooks.test.tsx assert the same things of each binding, as
src/vue/plain/RunGraph.test.ts and src/react/plain/RunGraph.test.tsx do of
the two run graphs: polling stops
on unmount, the guard refuses a duplicate, the probe re-asks when the entity
changes, a refreshed proposal keeps the reviewer’s decisions, a live-resource
registration survives re-renders. src/nuxt-ui/ProposalReviewCard.test.ts
mounts the SFC with the Nuxt UI primitives stubbed. The full propose → review →
apply → revert loop runs in Chromium against every host under one Playwright
spec in apps/e2e.
License
MIT
From @octabits-io/agent-ui@0.5.2 in the platform repository.