---
title: "@octabits-io/agent-ui"
summary: "The AI-UX capability in one package: framework-free state machines for AI-workflow UX and the headless review of a proposal (root), Vue composables (./vue) with the run graph in plain SVG (./vue/plain) and the Nuxt UI review surfaces as .vue source (./nuxt-ui/*), and React hooks (./react) with a plain-HTML review card (./react/plain)."
install: "pnpm add @octabits-io/agent-ui"
availability: "Available from November 2026"
version: "0.5.2"
license: "MIT"
related:
  - name: "@octabits-io/events"
    url: "https://octabits.io/platform/events.md"
  - name: "@octabits-io/proposal"
    url: "https://octabits.io/platform/proposal.md"
canonical_url: "https://octabits.io/platform/agent-ui"
---

# `@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.

```ts
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`](https://octabits.io/platform/proposal.md) and nothing further.

## When not to use it

- **You want a finished look.** The plain React card is semantic HTML with
  `data-slot`s 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`](https://octabits.io/platform/session.md) and
  [`@octabits-io/activity`](https://octabits.io/platform/activity.md); 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`](https://octabits.io/platform/events.md)
  (`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 `RunGraph`s 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.

```ts
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.

```ts
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

```ts
// 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:

```vue
<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](#the-run-graph).

## `./nuxt-ui/*` — the Nuxt UI cards

```ts
// app/components/AppProposalReviewCard.ts — register under your own name
export { default } from '@octabits-io/agent-ui/nuxt-ui/ProposalReviewCard.vue'
```

```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 `Icon`s: 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:

```ts
// 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`:

```css
@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

```tsx
<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}
/>
```

```vue
<!-- 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:

```tsx
<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

```tsx
<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:

```tsx
<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

```ts
// 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