Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/agui-activity-feed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@mobile-reality/mdma-agui': minor
---

Surface agentic activity — tool calls, run steps, and reasoning streams — as an ordered feed via the
`onActivity` option and `bridge.activity` (also returned from `useMdmaAgentStream`). Activity is
deliberately kept **out** of the MDMA document store, so agent chatter and rendered components stay
decoupled: render it as a timeline beside the documents, or ignore it entirely. Each `MdmaActivity`
carries a stable `id` (tool-call id / step handle / reasoning message id), a `kind`
(`'tool' | 'step' | 'reasoning'`), a `label`, a `status` (`'running' | 'done'`), and streamed
`detail` — accumulating tool args, the tool result, or the reasoning text.
12 changes: 12 additions & 0 deletions .changeset/agui-custom-event-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@mobile-reality/mdma-agui': minor
---

Accept MDMA on a dedicated `CUSTOM` event channel alongside inline assistant text. A backend that
would rather not interleave documents into visible prose can emit
`{ type: 'CUSTOM', name: 'mdma', value }` (the name is exported as `MDMA_CUSTOM_EVENT_NAME`), where
`value` is either the markdown string or `{ messageId?, markdown }`. Both channels feed the same
parse/store/render pipeline — out-of-band text is parsed immediately rather than throttled, since it
arrives complete — and each message reports where it came from via `message.source`
(`'text' | 'custom'`). Keeping documents off the prose channel means no markup leaks into the chat,
which is what a tool-calling agent wants when the document lives in a tool argument.
21 changes: 21 additions & 0 deletions .changeset/agui-interrupt-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@mobile-reality/mdma-agui': minor
---

Resume parked runs through AG-UI's native `interrupt` primitive. When a run finishes with an
`interrupt` outcome the bridge exposes the pending set as `bridge.interrupts` and fires
`onInterrupt`; answering the component an interrupt refers to now resolves **that** interrupt with
`runAgent({ resume })`, so the parked run continues with its state intact instead of starting a
fresh turn.

The new `resumeMode` option selects the strategy:

- `'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`).

**Behavior change:** a user decision previously always opened a fresh user turn. Under the new
`'auto'` default it will resolve a matching interrupt when the run is parked on one. Pass
`resumeMode: 'user-turn'` to keep the previous behavior. Returning `false` from `onAction`, or
supplying `resume`, still overrides resumption entirely.
13 changes: 13 additions & 0 deletions .changeset/agui-shared-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@mobile-reality/mdma-agui': minor
---

Track the agent's shared state (`STATE_SNAPSHOT` / `STATE_DELTA`, including JSON-patch deltas) as a
`componentId → values` map, exposed via the `onState` option and `bridge.state`, and use it to
hydrate MDMA stores. MDMA components are headless — a document describes intent and takes its values
from state — so this is what lets a form the agent renders come 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 currently looking at without
re-rendering the component.
176 changes: 168 additions & 8 deletions demo/src/docs/sections/IntegrationAgui.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Code } from '../Code.js';
import { Table } from '../Table.js';

export function IntegrationAgui() {
return (
Expand All @@ -15,6 +16,12 @@ export function IntegrationAgui() {
<code>@mobile-reality/mdma-agui</code> is the seam between them — a community-maintained
adapter, not a framework integration.
</p>
<p>
The two compose because MDMA components are <strong>headless</strong>: 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.
</p>

<h3>Install</h3>
<Code lang="bash">
Expand All @@ -41,18 +48,31 @@ export function Chat() {
return <MdmaAgentView agent={agent} />;
}`}</Code>

<p>For finer control, use the hook:</p>
<p>
For finer control, use the hook. Alongside <code>documents</code> it surfaces the agent's
activity, any interrupts the run is parked on, and its shared state:
</p>
<Code lang="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) => <MdmaDocument key={d.messageId} ast={d.ast} store={d.store} />);

return (
<>
{interrupts.length > 0 && <Banner>Waiting on: {interrupts.map((i) => i.id).join(', ')}</Banner>}
{documents.map((d) => (
<MdmaDocument key={d.messageId} ast={d.ast} store={d.store} />
))}
<ActivityFeed items={activity} />
<pre>{JSON.stringify(state, null, 2)}</pre>
</>
);
}`}</Code>

<h3>Headless usage</h3>
Expand All @@ -61,11 +81,119 @@ function Chat({ agent }) {

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<string, MdmaMessageState>
bridge.activity; // readonly MdmaActivity[]
bridge.interrupts; // readonly AguiInterrupt[] — what the run is parked on
bridge.state; // Readonly<MdmaSharedState>
await bridge.flush(); // force an immediate re-parse of buffered content

// later
bridge.dispose();`}</Code>

<h3>Two delivery channels</h3>
<p>
MDMA can reach the bridge two ways, and both feed the <em>same</em> parse → store → render
pipeline. Each message reports which channel it arrived on via <code>message.source</code>.
</p>
<Table
headers={['Channel', 'How the agent sends it', 'When to use']}
rows={[
[
"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.',
],
]}
/>
<p>
Either way the markdown still carries an <code>mdma</code> fence — same format, different
channel.
</p>

<h3>Shared state</h3>
<p>
Because components are headless, their values live in AG-UI's shared state. The bridge
tracks <code>STATE_SNAPSHOT</code> / <code>STATE_DELTA</code> as a{' '}
<code>componentId → values</code> map and hydrates MDMA stores from it — so a form the agent
renders comes up <strong>pre-filled</strong> from what it already knows.
</p>
<p>
Hydration is <strong>reactive</strong>: state arriving <em>after</em> a component is already
on screen is pushed into that live store too, so the agent can set a field the user is
looking at without re-rendering the form.
</p>
<p>
<code>initialState</code> takes the same shape at startup — for restoring a persisted
conversation so its forms, approvals, and tasklists render populated.
</p>
<Code lang="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),
});`}</Code>

<h3>Agentic activity</h3>
<p>
Tool calls, run steps, and reasoning streams surface as their own ordered feed —{' '}
<strong>deliberately separate</strong> 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.
</p>
<Table
headers={['Field', 'Meaning']}
rows={[
[
'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.',
],
]}
/>

<h3>Human-in-the-loop</h3>
<p>
When a run parks on AG-UI interrupts (a <code>RUN_FINISHED</code> carrying an{' '}
<code>interrupt</code> outcome), the bridge exposes the pending set as{' '}
<code>bridge.interrupts</code> and fires <code>onInterrupt</code>. Answering the matching
component resolves <em>that</em> interrupt with <code>runAgent({'{ resume }'})</code>, so
the parked run continues with its state intact instead of starting a fresh turn.
</p>
<p>
<code>resumeMode</code> controls how a user decision resumes the run:
</p>
<Table
headers={['Mode', 'Behavior']}
rows={[
[
"'auto' (default)",
'If the parked run has an interrupt matching the answered component, resolve that interrupt. 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).'],
]}
/>
<p>
For full control, <code>onAction</code> returning <code>false</code> hands resumption to
you, and the <code>resume</code> option replaces the built-in interrupt and user-turn paths
entirely.
</p>

<h3>How it works</h3>
<p>
<strong>Stream → render.</strong> On each streamed content event the bridge reads the
Expand All @@ -78,17 +206,49 @@ bridge.dispose();`}</Code>
<strong>Action → resume.</strong> The bridge listens for the decision events —{' '}
<code>ACTION_TRIGGERED</code> (button, form submit, tasklist completion),{' '}
<code>APPROVAL_GRANTED</code> / <code>APPROVAL_DENIED</code> (approval-gate), and{' '}
<code>INTEGRATION_CALLED</code> (webhook trigger). By default it packages the decision as a
user turn and calls <code>agent.addMessage()</code> + <code>agent.runAgent()</code>. Return{' '}
<code>false</code> from <code>onAction</code> to take over — e.g. resolve AG-UI's native
interrupt so the parked run resumes with state intact.
<code>INTEGRATION_CALLED</code> (webhook trigger) — then resumes according to{' '}
<code>resumeMode</code>: resolving the matching interrupt where there is one, otherwise
packaging the decision as a user turn (<code>addMessage</code> + <code>runAgent</code>).
</p>
<p>
A tasklist resumes the run only on the transition into <em>all items checked</em> (its{' '}
<code>onComplete</code> action), not on every toggle — individual <code>FIELD_CHANGED</code>{' '}
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).
</p>

<h3>Options</h3>
<Table
headers={['Option', 'Purpose']}
rows={[
[
'onDocument',
'A message’s store was created or 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 take over resumption.'],
['resumeMode', "How decisions resume the run — 'auto' · 'interrupt' · 'user-turn'."],
['resume', 'Replace the built-in resume behavior entirely.'],
['initialState', 'Seed component values when stores are first created.'],
['throttleMs', 'Debounce between re-parses of a streaming message. Default 150.'],
[
'createRegistry',
'Registry factory for the document store (defaults to the core attachables).',
],
]}
/>

<h3>End-to-end example</h3>
<p>
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{' '}
<code>examples/integrations/ag-ui</code> in the repo.
</p>
</>
);
}
14 changes: 14 additions & 0 deletions examples/integrations/ag-ui/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copy to `.env` (same folder) and fill in. `pnpm backend` auto-loads it.

# Your OpenRouter key (required) — https://openrouter.ai/keys
OPENROUTER_API_KEY=

# Any OpenRouter model slug. Examples:
# anthropic/claude-3.5-sonnet
# openai/gpt-4o
# google/gemini-flash-1.5
# meta-llama/llama-3.3-70b-instruct
MDMA_MODEL=anthropic/claude-3.5-sonnet

# Backend port (default 8787). The FE expects 8787 — change src/App.tsx `BE` if you change this.
# PORT=8787
3 changes: 3 additions & 0 deletions examples/integrations/ag-ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.env
node_modules
dist
Loading