diff --git a/.changeset/wet-banks-sink.md b/.changeset/wet-banks-sink.md
new file mode 100644
index 0000000..f67fd9e
--- /dev/null
+++ b/.changeset/wet-banks-sink.md
@@ -0,0 +1,15 @@
+---
+"@mobile-reality/mdma-agui": patch
+---
+
+Bring the package README up to date with the bridge it ships. It still described the 0.2.x surface,
+so 0.3.0 published with docs that were wrong in places: they claimed a user decision always resumes
+via a fresh `addMessage` + `runAgent` turn (that is now only the fallback under the `'auto'`
+`resumeMode`), and listed `createMdmaAgentBridge` as returning `{ documents, flush, dispose }` and
+the hook as `{ documents, bridge }` — both omitting `activity`, `interrupts`, and `state`.
+
+Also documents what 0.3.0 added but never explained: the `CUSTOM` delivery channel, shared state and
+reactive hydration, the activity feed, and human-in-the-loop interrupts — plus the six missing
+options (`initialState`, `onActivity`, `onState`, `onInterrupt`, `resumeMode`, `now`), the
+`MDMA_CUSTOM_EVENT_NAME` / `createDefaultRegistry` exports, and the `INTEGRATION_CALLED` (webhook)
+and tasklist-completion decisions. Fixes a sentence left truncated by a stale link removal.
diff --git a/packages/agui/README.md b/packages/agui/README.md
index 69a0288..3e9e337 100644
--- a/packages/agui/README.md
+++ b/packages/agui/README.md
@@ -2,15 +2,20 @@
Bridge [MDMA](https://github.com/MobileReality/mdma) interactive documents onto the
[AG-UI protocol](https://github.com/ag-ui-protocol/ag-ui). An AG-UI agent streams MDMA
-(forms, tables, approval gates) as message text or a tool-call payload; this package renders it
-live and routes the user's actions — submit, approve, deny — back into the agent run, closing the
-human-in-the-loop.
+(forms, tables, approval gates) as message text or on a dedicated `CUSTOM` event; this package
+renders it live, hydrates it from the agent's shared state, and routes the user's actions — submit,
+approve, deny — back into the run, closing the human-in-the-loop.
MDMA owns the **decision surface** (validated, audited, PII-aware components); AG-UI owns the
**control primitive** (suspend/resume via its `interrupt` building block). This adapter is the seam.
+The two compose because MDMA components are **headless**: a document describes intent (fields,
+types, actions) and takes its values from state. AG-UI already standardizes the three things that
+needs — streaming documents, carrying shared state, and pausing for a human — so the bridge maps
+each onto MDMA instead of inventing plumbing.
+
> **Layering.** AG-UI is transport; MDMA is payload. This package is a community-maintained
-> adapter, not a framework integration — see [`mdma-agui-integration-plan.md`](../../mdma-agui-integration-plan.md).
+> adapter, not a framework integration.
## Install
@@ -42,20 +47,31 @@ export function Chat() {
}
```
-For finer control, use the hook:
+For finer control, use the hook. Alongside `documents` it surfaces the agent's activity, any
+interrupts the run is parked on, and its shared state:
```tsx
import { useMdmaAgentStream } from '@mobile-reality/mdma-agui/react';
import { MdmaDocument } from '@mobile-reality/mdma-renderer-react';
function Chat({ agent }) {
- const { documents } = useMdmaAgentStream(agent, {
- // Return false to resume the run yourself (e.g. resolve an AG-UI interrupt).
+ const { documents, activity, interrupts, state, bridge } = useMdmaAgentStream(agent, {
+ // Return false to resume the run yourself.
onAction: async (action, message) => {
console.log('user decided', action.type, 'in', message.messageId);
},
});
- return documents.map((d) => );
+
+ return (
+ <>
+ {interrupts.length > 0 && Waiting on: {interrupts.map((i) => i.id).join(', ')}}
+ {documents.map((d) => (
+
+ ))}
+
+
{JSON.stringify(state, null, 2)}
+ >
+ );
}
```
@@ -68,51 +84,147 @@ import { createMdmaAgentBridge } from '@mobile-reality/mdma-agui';
const bridge = createMdmaAgentBridge(agent, {
onDocument: (message) => renderSomewhere(message.ast, message.store),
+ onActivity: (item, feed) => renderActivity(feed),
+ onState: (state) => renderState(state),
+ onInterrupt: (pending) => renderGateBanner(pending),
});
+bridge.documents; // ReadonlyMap
+bridge.activity; // readonly MdmaActivity[]
+bridge.interrupts; // readonly AguiInterrupt[] — what the run is parked on
+bridge.state; // Readonly
+await bridge.flush(); // force an immediate re-parse of buffered content
+
// later
bridge.dispose();
```
+## Two delivery channels
+
+MDMA can reach the bridge two ways, and both feed the *same* parse → store → render pipeline. Each
+message reports which channel it arrived on via `message.source`.
+
+| Channel | How the agent sends it | When to use |
+|---|---|---|
+| **Inline** — `source: 'text'` | The document sits in the assistant's streamed prose (`TEXT_MESSAGE_CONTENT`). Re-parsed with throttling as it streams. | The agent writes MDMA as part of its reply. |
+| **Out-of-band** — `source: 'custom'` | A `CUSTOM` event named `mdma` (`MDMA_CUSTOM_EVENT_NAME`), whose `value` is the markdown string or `{ messageId?, markdown }`. Parsed immediately — the text is already complete. | A tool-calling agent that puts the document in a tool argument, keeping prose and UI on separate channels so no markup leaks into the chat. |
+
+Either way the markdown still carries an `mdma` fence — same format, different channel.
+
+## Shared state
+
+Because components are headless, their values live in AG-UI's shared state. The bridge tracks
+`STATE_SNAPSHOT` / `STATE_DELTA` (including JSON-patch deltas) as a `componentId → values` map and
+hydrates MDMA stores from it — so a form the agent renders comes up **pre-filled** from what it
+already knows.
+
+Hydration is **reactive**: state arriving *after* a component is already on screen is dispatched
+into that live store too, so the agent can set a field the user is looking at without re-rendering
+the form.
+
+`initialState` takes the same shape at startup — for restoring a persisted conversation so its
+forms, approvals, and tasklists render populated.
+
+```ts
+const bridge = createMdmaAgentBridge(agent, {
+ // Seed stores as they're created (e.g. a conversation fetched from your backend).
+ initialState: { 'signup-form': { email: 'ada@example.com' } },
+ onState: (state) => console.log('agent knows', state),
+});
+```
+
+## Agentic activity
+
+Tool calls, run steps, and reasoning streams surface as their own ordered feed — **deliberately
+separate** from MDMA. They never enter a document store, so agent chatter and rendered components
+stay decoupled: render the feed as a timeline beside the documents, or ignore it entirely.
+
+| Field | Meaning |
+|---|---|
+| `id` | Stable across the item's lifetime (tool-call id, step handle, reasoning message id). |
+| `kind` | `'tool'` · `'step'` · `'reasoning'` |
+| `label` | The tool name, the step name, or `reasoning`. |
+| `status` | `'running'` → `'done'` |
+| `detail` | Streamed detail — accumulating tool args, the tool result, or the reasoning text. |
+
+## Human-in-the-loop
+
+When a run parks on AG-UI interrupts (a `RUN_FINISHED` carrying an `interrupt` outcome), the bridge
+exposes the pending set as `bridge.interrupts` and fires `onInterrupt`. Answering the component an
+interrupt refers to resolves **that** interrupt with `runAgent({ resume })`, so the parked run
+continues with its state intact instead of starting a fresh turn.
+
+`resumeMode` controls how a user decision resumes the run:
+
+| Mode | Behavior |
+|---|---|
+| `'auto'` *(default)* | Resolve a matching interrupt if the run is parked on one; otherwise fall back to a fresh user turn. |
+| `'interrupt'` | Only ever resolve a matching interrupt; if none matches, do nothing. |
+| `'user-turn'` | Always open a fresh user turn (`addMessage` + `runAgent`). |
+
+For full control, `onAction` returning `false` hands resumption to you, and `resume` replaces the
+built-in interrupt and user-turn paths entirely.
+
## How it works
**Stream → render.** On each `onTextMessageContentEvent`, the bridge reads the *accumulated*
`textMessageBuffer` (no delta bookkeeping), gates on a cheap `containsMdma()` fence check,
-throttles re-parsing (~150 ms), and feeds the AST into a document store. The store is created
-**once per message** and updated in place with `store.updateAst()` afterward, so in-flight form
-edits and focus survive streaming. "Latest content wins" guards async parse ordering.
+throttles re-parsing (~150 ms), and feeds the AST into a document store. Out-of-band `CUSTOM`
+documents go through the same path but parse immediately, since their text arrives complete. The
+store is created **once per message** and updated in place with `store.updateAst()` afterward, so
+in-flight form edits and focus survive streaming. "Latest content wins" guards async parse ordering.
**Action → resume.** The bridge listens on `store.getEventBus().onAny()` and switches on the
-decision events — `ACTION_TRIGGERED` (button / form submit), `APPROVAL_GRANTED`, and
-`APPROVAL_DENIED` (approval-gate). By default it packages the decision as a user turn and calls
-`agent.addMessage()` + `agent.runAgent()`. Return `false` from `onAction` to take over — e.g.
-resolve AG-UI's native interrupt so the parked run resumes with state intact.
+decision events — `ACTION_TRIGGERED` (button, form submit, tasklist completion),
+`APPROVAL_GRANTED` / `APPROVAL_DENIED` (approval-gate), and `INTEGRATION_CALLED` (webhook trigger)
+— then resumes according to `resumeMode`: resolving the matching interrupt where there is one,
+otherwise packaging the decision as a user turn (`addMessage` + `runAgent`).
+
+A tasklist resumes the run only on the transition into *all items checked* (its `onComplete`
+action), not on every toggle — individual `FIELD_CHANGED` edits are ignored, the same way
+in-progress form typing is. A webhook routes its trigger and request shape (real HTTP execution is
+handled by your agent or the webhook engine).
## API
| Export | Description |
|---|---|
-| `createMdmaAgentBridge(agent, options)` | Headless bridge. Returns `{ documents, flush, dispose }`. |
-| `parseMdma(markdown, { existingStore?, createRegistry? })` | Parse text → `{ ast, store }`, reusing a store when given. |
+| `createMdmaAgentBridge(agent, options)` | Headless bridge → `{ documents, activity, interrupts, state, flush, dispose }`. |
+| `MDMA_CUSTOM_EVENT_NAME` | The `CUSTOM` event name (`'mdma'`) carrying out-of-band documents. |
+| `parseMdma(markdown, { existingStore?, createRegistry?, initialState? })` | Parse text → `{ ast, store }`, reusing a store when given. |
| `containsMdma(text)` | Cheap gate: does the buffer contain an `mdma` fence? |
-| `useMdmaAgentStream(agent, options)` *(./react)* | React hook → `{ documents, bridge }`. |
+| `createDefaultRegistry()` | The default attachable registry used by new stores. |
+| `useMdmaAgentStream(agent, options)` *(./react)* | React hook → `{ documents, activity, interrupts, state, bridge }`. |
| `MdmaAgentView` *(./react)* | Drop-in component rendering every streamed document. |
+Types: `MdmaAgentBridge`, `MdmaAgentBridgeOptions`, `MdmaMessageState`, `MdmaSourceOrigin`,
+`MdmaActionEvent`, `MdmaActivity`, `MdmaActivityKind`, `MdmaActivityStatus`, `MdmaSharedState`, plus
+the structural AG-UI types (`AguiAgent`, `AguiInterrupt`, `AguiResumeEntry`, …).
+
### `options` (both `createMdmaAgentBridge` and the hook/view)
-- `throttleMs?` — re-parse debounce window (default `150`).
-- `createRegistry?` — attachable registry factory (defaults to the core attachables).
-- `onDocument?` — fires when a message's store is created/updated (render hook).
-- `onAction?` — fires on a user decision; return `false` to suppress the default resume.
-- `resume?` — fully replace the default `addMessage` + `runAgent` resume.
+| Option | Purpose |
+|---|---|
+| `onDocument?` | A message's store was created/updated from newly parsed MDMA — the render hook. |
+| `onActivity?` | A tool call / step / reasoning item was created or advanced. Observational. |
+| `onState?` | The agent's shared state changed. Observational. |
+| `onInterrupt?` | The run parked on human-in-the-loop interrupts. |
+| `onAction?` | A user decision fired; return `false` to suppress the default resume. |
+| `resumeMode?` | How decisions resume the run — `'auto'` (default) · `'interrupt'` · `'user-turn'`. |
+| `resume?` | Fully replace the built-in resume behavior. |
+| `initialState?` | Seed component values when stores are first created. |
+| `throttleMs?` | Re-parse debounce window (default `150`). |
+| `createRegistry?` | Attachable registry factory (defaults to the core attachables). |
+| `now?` | Injectable clock (ms). Defaults to `Date.now`; overridden in tests. |
## AG-UI coupling
The headless core is written against a **minimal structural agent interface** in
-[`src/types.ts`](src/types.ts) — the small slice of `@ag-ui/client`'s `AbstractAgent` /
-`AgentSubscriber` and `@ag-ui/core`'s `Message` it touches (`subscribe`, `runAgent`, `addMessage`,
-`onTextMessageContentEvent`). A real `HttpAgent` satisfies it by shape, so there is no hard build
-dependency on AG-UI and all coupling is isolated to that one file.
+[`src/types.ts`](src/types.ts) — the slice of `@ag-ui/client`'s `AbstractAgent` / `AgentSubscriber`
+and `@ag-ui/core`'s `Message` it touches (`subscribe`, `runAgent`, `addMessage`, and the text,
+custom, tool/step/reasoning, state, and run-lifecycle subscriber hooks). A real `HttpAgent`
+satisfies it by shape, so there is no hard build dependency on AG-UI and all coupling is isolated
+to that one file.
It is **not a blind shim**: [`tests/agui-conformance.ts`](tests/agui-conformance.ts) asserts at
type-check time (against the installed `@ag-ui/*`) that a real `AbstractAgent` is assignable to
@@ -120,6 +232,12 @@ our `AguiAgent` and our subscriber is accepted by the real `AgentSubscriber`. If
drifts, `pnpm typecheck` fails there — turning silent runtime drift into a build error. The
conformance file is excluded from the published build.
+## Example
+
+A runnable backend + React frontend using every piece above — a tool-calling agent, MDMA over
+`CUSTOM` events, shared state, interrupts, and the activity feed — lives in
+[`examples/integrations/ag-ui`](../../examples/integrations/ag-ui).
+
## License
MIT