@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-i18n and Tailwind v4. On another design system, take useProposalReview and 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. createStore is 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 createAiProgressCore and the registry, coupled to a router this package does not know.
  • The result is a value, not a proposal. createProposalReviewStore reviews a Proposal. A workflow whose output is a text to read has nothing to accept or reject: poll it with createWorkflowPoller and 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/session and @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); useEventStream and useLiveResource in 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/create carries its value in edits, 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 carries calibrated so 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-way activeVariant for a tab control, dangling, canApply, and toggle / setValue / toDecision(). This is what ProposalReviewCard.vue is 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, reset and toAnswers(). DecisionReviewCard.vue is built on it.
  • useAiWorkflow(options?) / useAiWorkflowGuard(options) — poll-driven workflow state over an injected pollFn; the guard adds mount-time rehydration and a duplicate-safe trigger.
  • createAiProgressCore(options) — the setup body of the app’s own progress store (Pinia or otherwise): cross-page tracking with completionSignal / appliedSignal.
  • useAiCardState(store, entityRef), useActiveAiWorkflowProbe(options), useEventStream(options), useLiveResource(keys, refresh), useObservable(observable).
  • agentUiMessagesEn — English defaults for every ai.review.* and ai.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’s reviewMessageTemplatesEn nested under ai.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 a key per entity so a new entity gets a fresh guard.
  • useAiProgress(store) — subscribes a component to the app’s one createAiProgressStore(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.