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
8 changes: 8 additions & 0 deletions .changeset/attachables-core-remove-dead-onaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@mobile-reality/mdma-attachables-core": patch
---

Remove the unused `onAction` methods from the core attachable handlers (form, button, tasklist,
table, callout, approval-gate, webhook). They were never invoked — renderers dispatch store actions
directly — so this is a dead-code cleanup with no behavioral change. Each handler's `definition` and
`initialize` are unchanged.
5 changes: 4 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
}
12 changes: 12 additions & 0 deletions .changeset/document-store-initial-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@mobile-reality/mdma-runtime": minor
"@mobile-reality/mdma-agui": minor
---

Add an `initialState` option to `createDocumentStore` for hydrating component values at store
creation — e.g. restoring a persisted conversation fetched from a backend. Keyed by component id →
its `values` map (symmetric with `getState()`), it overlays AST defaults **without emitting audit
events or marking fields `touched`**, and applies only to freshly-created components so a streaming
re-parse never clobbers in-flight edits. `mdma-agui` threads `initialState` through `parseMdma`,
the bridge, and `MdmaAgentView`/`useMdmaAgentStream`, so re-opened conversations render
pre-populated.
9 changes: 9 additions & 0 deletions .changeset/mdma-agui-initial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@mobile-reality/mdma-agui": minor
---

Add `@mobile-reality/mdma-agui`: a bridge that renders MDMA interactive documents streamed over
the AG-UI protocol and routes user actions (submit / approve / deny) back into the agent run.
Ships a headless core (`createMdmaAgentBridge`) plus an optional React layer
(`useMdmaAgentStream`, `MdmaAgentView`). AG-UI coupling is isolated to a minimal structural agent
interface, so any `@ag-ui/client` `HttpAgent` works without a hard dependency.
11 changes: 11 additions & 0 deletions .changeset/parser-streaming-unknown-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@mobile-reality/mdma-parser": patch
"@mobile-reality/mdma-agui": patch
---

Stop flashing "Unknown component type" while a block is still streaming. When an `mdma` fence is
not yet closed, a valid-YAML-but-unknown type (e.g. a half-streamed `approval-gat` before
`approval-gate` finishes) is now left as a pending block (loading skeleton) instead of being
rendered as an unknown-type error. Once the fence closes, a genuinely unknown type still surfaces
the error as before. Known valid types continue to render live during streaming. The `mdma-agui`
adapter now threads the source into `unified.run()` so the parser can see the raw fences.
10 changes: 10 additions & 0 deletions .changeset/tasklist-webhook-routable-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@mobile-reality/mdma-renderer-react": minor
"@mobile-reality/mdma-agui": minor
---

Support tasklist completion and webhook triggers as routable events. The tasklist renderer now
emits `ACTION_TRIGGERED` (its `onComplete` action) on the transition into all-items-checked, and
the webhook renderer gains a trigger button that emits `INTEGRATION_CALLED`. The `mdma-agui`
bridge routes both back into the agent run — alongside form submit, button, and approve/deny — so
completing a checklist or firing a webhook resumes the AG-UI conversation.
9 changes: 9 additions & 0 deletions .changeset/updateast-retype-on-type-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@mobile-reality/mdma-runtime": patch
---

Fix `DocumentStore.updateAst` freezing a component's `type` for the lifetime of its id. During
streaming, an early partial parse can produce a placeholder/truncated type (e.g. `approval-gat`
before `approval-gate` finishes streaming); `updateAst` now re-initializes a component when its
type changes between parses, while still preserving in-flight state (values, touched) when the
type is unchanged.
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ fields:
type: textarea
label: "Reason for Visit"
required: true
onSubmit: submit-intake
```

```mdma
Expand Down Expand Up @@ -189,7 +190,7 @@ Each cell shows the pass rate of the model-specialized MDMA_FIXER prompt variant

| Component | Type key | Description |
|-----------|----------|-------------|
| **Form** | `form` | Multi-field forms with text, email, number, select, textarea, checkbox, datetime, and file fields. Supports validation, required fields, default values, and sensitive (PII) flags. |
| **Form** | `form` | Multi-field forms with text, number, email, date, select, checkbox, textarea, and file fields. Supports validation, required fields, default values, and sensitive (PII) flags. |
| **Button** | `button` | Action buttons with `primary`, `secondary`, and `danger` variants. |
| **Tasklist** | `tasklist` | Interactive checkbox task items with labels. |
| **Table** | `table` | Data tables with typed columns and row data. |
Expand Down Expand Up @@ -321,6 +322,7 @@ fields:
- name: actual
type: textarea
label: "Actual Behavior"
onSubmit: submit-bug-report
\`\`\``,
});

Expand Down Expand Up @@ -367,7 +369,6 @@ function App({ ast, store }) {
| `@mobile-reality/mdma-validator` | Static analysis engine with 17 lint rules covering YAML correctness, schema conformance, ID uniqueness, binding syntax, action references, PII sensitivity, expected component verification, and flow ordering. Includes 6 auto-fix strategies and fuzzy type/ID suggestions. Powers programmatic validation in CI pipelines and custom tooling. |
| `@mobile-reality/mdma-cli` | Interactive CLI tool for creating custom MDMA prompts. Opens a local web app where you visually select components, configure fields, set domain rules and trigger conditions, then an LLM generates a tailored `customPrompt` for use with `buildSystemPrompt()`. Also includes a `validate` command for static document analysis. |
| `@mobile-reality/mdma-mcp` | MCP (Model Context Protocol) server that exposes MDMA spec, prompts, and tooling to AI assistants. Tools: `get-spec`, `get-prompt` (with optional `variantId` for model-optimised prompts), `list-prompt-variants`, `build-system-prompt`, `validate-prompt`, `list-packages`. Works with Claude Desktop, VS Code, Cursor, and any MCP-compatible client. |
| `@mobile-reality/mdma-evals` | LLM evaluation suite built on promptfoo with 4 test suites: base generation quality (25 tests), custom prompt compliance (10 tests), multi-turn conversation handling (11 conversations, 25 turns), and prompt builder verification (25 tests). Validates that AI-generated MDMA documents are structurally correct and semantically appropriate. |

## Architecture

Expand All @@ -381,7 +382,6 @@ function App({ ast, store }) {
└── @mobile-reality/mdma-renderer-react React components
@mobile-reality/mdma-cli CLI prompt builder + validation
@mobile-reality/mdma-mcp MCP server for AI assistants
@mobile-reality/mdma-evals LLM evaluation suite (promptfoo)
```

## Getting Started
Expand Down Expand Up @@ -453,26 +453,27 @@ const result = validate(markdown, {
| `duplicate-ids` | error | yes | All component IDs are unique. Auto-fix appends `-1`, `-2` suffixes. |
| `id-format` | warning | yes | IDs follow kebab-case (`my-component-id`). Auto-fix converts camelCase, snake_case, PascalCase and updates all references. |
| `binding-syntax` | error/warning | yes | `{{binding}}` expressions are well-formed. Catches empty `{{ }}`, extra whitespace `{{ path }}`, and single-brace `{path}`. |
| `action-references` | warning | yes | `onSubmit`, `onAction`, `onComplete`, `onApprove`, `onDeny`, `trigger` reference existing component IDs. Suggests near-matches for typos. |
| `form-submit-action` | error | -- | Every `type: form` component declares a non-empty `onSubmit` action. |
| `sensitive-flags` | warning | yes | Form fields and table columns with PII-like names (email, phone, ssn, address, etc.) have `sensitive: true`. Supports custom PII patterns. |
| `required-markers` | info | -- | Suggests `required: true` for fields named `name`, `email`, `title`, `summary`. |
| `thinking-block` | warning/info | -- | If a thinking block is present, it should be the first component and only one should exist. |
| `table-data-keys` | warning | -- | Data row keys match defined column keys. Flags extra keys and columns with no matching data. |
| `select-options` | warning | -- | `type: select` fields have `options` defined as `[{label, value}]` objects. |
| `chart-validation` | warning | -- | Chart CSV data has headers + data rows. `xAxis`/`yAxis` reference actual CSV column headers. |
| `placeholder-content` | info | -- | Catches `TODO`, `TBD`, `FIXME`, `...`, `lorem ipsum` in content fields. |
| `flow-ordering` | error/info | -- | Forward-only action references, no circular refs, one interactive component type per message. Detects regenerated components from prior conversation turns. |
| `flow-ordering` | warning | -- | Forward-only action references (targets defined later in the document), no circular refs, and multi-step flows flagged to be split across messages. |
| `single-interactive-component` | warning | -- | At most one interactive component (form, button, webhook, approval-gate, tasklist) per message. |
| `expected-components` | error | -- | Verifies that components present in the message match their expected types, form fields, and table columns. Components not in the message are silently skipped — useful for multi-turn flows where you pass all expected components upfront. |

### Auto-fix Pipeline

When `autoFix: true` (default), 6 fix strategies run in strict dependency order:

1. **id-format** — normalize IDs to kebab-case, update all cross-references
2. **duplicate-ids** — deduplicate after normalization
3. **binding-syntax** — fix `{x}` -> `{{x}}`, strip whitespace
4. **sensitive-flags** — add `sensitive: true` to PII fields
5. **action-references** — remove invalid references
1. **thinking-block** — merge stray thinking blocks into one and move it to the top
2. **id-format** — normalize IDs to kebab-case, update all cross-references
3. **duplicate-ids** — deduplicate after normalization
4. **binding-syntax** — fix `{x}` -> `{{x}}`, strip whitespace
5. **sensitive-flags** — add `sensitive: true` to PII fields
6. **schema-conformance** — patch missing labels/headers/content, infer field types, wrap bare bindings, re-validate with Zod

### Expected Components
Expand Down
24 changes: 20 additions & 4 deletions demo/src/docs/DocsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { Packages } from './sections/Packages.js';
import { PromptMatrix } from './sections/PromptMatrix.js';
import { Integrations, INTEGRATIONS } from './sections/Integrations.js';
import { IntegrationLangchain } from './sections/IntegrationLangchain.js';
import { Usage } from './sections/Usage.js';
import { IntegrationAgui } from './sections/IntegrationAgui.js';
import { Usage, UsageHydrationPreview } from './sections/Usage.js';
import { Validator } from './sections/Validator.js';

const INTEGRATION_COMPONENTS: Record<string, React.ComponentType> = {
langchain: IntegrationLangchain,
'ag-ui': IntegrationAgui,
};

interface Section {
Expand Down Expand Up @@ -54,6 +56,7 @@ function navigateDocs(slug: string) {
export function DocsView() {
const [active, setActiveState] = useState(getDocsSlug);
const [selectedComponent, setSelectedComponent] = useState('form');
const [usageExampleOpen, setUsageExampleOpen] = useState(false);

useEffect(() => {
function sync() {
Expand All @@ -68,7 +71,9 @@ export function DocsView() {
setActiveState(slug);
}

const showPreview = active === 'components';
const showComponentsPreview = active === 'components';
const showUsagePreview = active === 'usage' && usageExampleOpen;
const showPreview = showComponentsPreview || showUsagePreview;
const previewEntry = COMPONENTS.find((c) => c.type === selectedComponent) ?? COMPONENTS[0];

const isPackagesActive = active === 'packages' || active.startsWith('packages/');
Expand All @@ -91,8 +96,15 @@ export function DocsView() {
const SectionContent = section?.component ?? null;

function renderContent() {
if (showPreview)
if (showComponentsPreview)
return <Components selected={selectedComponent} onSelect={setSelectedComponent} />;
if (active === 'usage')
return (
<Usage
exampleOpen={usageExampleOpen}
onToggleExample={() => setUsageExampleOpen((v) => !v)}
/>
);
if (activePackage) return <PackageDetail pkg={activePackage} onNavigate={setActive} />;
if (active === 'packages') return <Packages onNavigate={setActive} />;
if (ActiveIntegration) return <ActiveIntegration />;
Expand Down Expand Up @@ -157,7 +169,11 @@ export function DocsView() {

{showPreview && (
<aside className="docs-preview-panel">
<ComponentPreview key={selectedComponent} entry={previewEntry} />
{showComponentsPreview ? (
<ComponentPreview key={selectedComponent} entry={previewEntry} />
) : (
<UsageHydrationPreview />
)}
</aside>
)}
</div>
Expand Down
94 changes: 94 additions & 0 deletions demo/src/docs/sections/IntegrationAgui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Code } from '../Code.js';

export function IntegrationAgui() {
return (
<>
<h2>AG-UI Protocol</h2>
<p>
Stream MDMA documents from an{' '}
<a href="https://github.com/ag-ui-protocol/ag-ui" target="_blank" rel="noreferrer">
AG-UI
</a>{' '}
agent and route the user's decisions back into the run. AG-UI is the{' '}
<strong>transport</strong> (suspend/resume via its <code>interrupt</code> primitive); MDMA is
the <strong>payload</strong> (validated, audited, PII-aware components).{' '}
<code>@mobile-reality/mdma-agui</code> is the seam between them — a community-maintained
adapter, not a framework integration.
</p>

<h3>Install</h3>
<Code lang="bash">
{
'npm install @mobile-reality/mdma-agui @ag-ui/client @ag-ui/core @mobile-reality/mdma-parser @mobile-reality/mdma-runtime @mobile-reality/mdma-spec @mobile-reality/mdma-attachables-core\n# React layer only:\nnpm install @mobile-reality/mdma-renderer-react react'
}
</Code>
<p>
All AG-UI, MDMA, and React packages are <strong>peer dependencies</strong> — you bring the
versions your app already uses. <code>@mobile-reality/mdma-renderer-react</code> and{' '}
<code>react</code> are optional (the headless core works without them).
</p>

<h3>React usage</h3>
<Code lang="tsx">{`import { HttpAgent } from '@ag-ui/client';
import { MdmaAgentView } from '@mobile-reality/mdma-agui/react';
import '@mobile-reality/mdma-renderer-react/styles.css';

const agent = new HttpAgent({ url: '/api/agent' });

// Renders every MDMA document the agent streams;
// form submits and approvals resume the run automatically.
export function Chat() {
return <MdmaAgentView agent={agent} />;
}`}</Code>

<p>For finer control, use the hook:</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).
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} />);
}`}</Code>

<h3>Headless usage</h3>
<p>No React required — subscribe and drive rendering yourself:</p>
<Code lang="ts">{`import { createMdmaAgentBridge } from '@mobile-reality/mdma-agui';

const bridge = createMdmaAgentBridge(agent, {
onDocument: (message) => renderSomewhere(message.ast, message.store),
});

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

<h3>How it works</h3>
<p>
<strong>Stream → render.</strong> On each streamed content event the bridge reads the
accumulated buffer, gates on a cheap <code>mdma</code>-fence check, throttles re-parsing
(~150&nbsp;ms), and feeds the AST into a document store. The store is created{' '}
<strong>once per message</strong> and updated in place with <code>store.updateAst()</code>,
so in-flight form edits and focus survive streaming.
</p>
<p>
<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.
</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>
</>
);
}
6 changes: 6 additions & 0 deletions demo/src/docs/sections/Integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const INTEGRATIONS = [
label: 'LangChain.js',
description: 'Use MDMA inside a LangChain chain or agent — backend Node.js service.',
},
{
slug: 'ag-ui',
label: 'AG-UI',
description:
'Stream MDMA over the AG-UI protocol and resume the agent run on user actions — human-in-the-loop.',
},
];

export function Integrations({ onNavigate }: IntegrationsProps) {
Expand Down
Loading