MarkdownUI
Interfaces are about to become readable and streamable.
Draft v0.0.2 · June 23, 2026
Watch any AI agent work and you’ll see the same move. It writes a few sentences, then offers a button or two underneath: pick a tone, confirm an action, fill in a field. ChatGPT does it. Telegram bots do it. Almost every framework reinvents it as a blob of JSON the chat window knows how to draw and no person would ever want to read.
I think that’s backwards. An agent’s output should be one thing. A stream that reads as a transcript and works as an interface at the same time, instead of text with the controls bolted on beside it as a separate structure every platform has to handle its own way.
We already have half of this. Markdown is what models write and what people read, and it quietly became the common language of AI. MarkdownUI is a sketch of the other half. It adds the smallest set of things that make a stream interactive without making it harder to read. It looks like this:
I can draft the email. Pick a tone first.
:::radio {#tone bind=email.tone label="Tone"}
- ( ) Direct {value=direct}
- (x) Warm {value=warm}
- ( ) Formal {value=formal}
:::
:::textarea {#extra bind=email.extra label="Anything to include?" rows=3}
Mention that we're flexible on timing.
:::
:::button {#draft action=draft_email kind=primary}
Draft email
:::I can draft the email. Pick a tone first.
As plain text, that still reads fine. In a renderer it becomes a live control, and tapping it sends one small, explicit event back to the agent. The same output works for a person reading it and a program drawing it.
Where this fits
I didn’t invent the problem, and I’m not the first one at it. The serious work has split into three camps, and each one gives something up. Iframe formats, like Anthropic and OpenAI’s MCP Apps and the ChatGPT Apps SDK, can render anything, but they ship opaque bundled HTML that no person can read and that shows nothing without a renderer. JSON-tree formats, like Google’s A2UI, Microsoft Adaptive Cards, Slack Block Kit, and Telegram’s inline keyboards, are safe and render natively, but they are a verbose blob nobody reads, and they also vanish without a renderer. Streaming-Markdown renderers like Vercel’s Streamdown read perfectly and stream cleanly, but they have no interactivity at all.
| Approach | Reads as text | Streams | Interactive |
|---|---|---|---|
| Iframe / HTML (MCP Apps, ChatGPT Apps) | No | Loads whole | Fully |
| JSON tree (A2UI, Adaptive Cards, Block Kit) | Barely | No | Fully |
| Plain Markdown (Streamdown) | Yes | Yes | No |
| MarkdownUI | Yes | Yes | Lightly |
The empty seat is the one worth wanting. It would read as well as Markdown, stream as cleanly as an event protocol, fall back to plain text, and still carry a little interactivity. The rest of the field seems to be circling the same idea from other directions. Vercel recently paused its “stream the React component” approach and went back to streaming data and rendering on the client. Google describes A2UI as “UI that’s safe like data, expressive like code.” This is the same instinct from the one angle they leave out, which is that it should also be readable like text.
It loses badly past a certain point. Anything beyond a button, a choice, or a short form is out of reach. There is no layout engine, no charts, no type system, and no real two-way state, so the moment you need those a JSON tree or an iframe is the right tool. That is why this isn’t meant as a tenth standard to adopt. It is closer to a point of view. The nearest event model already exists in the AG-UI protocol, and the most useful version of this idea probably runs on rails like that rather than competing with them. The rest of the page works the idea out in full, and every example below is live.
Design principles
- Readable as plain text. A transcript should still make sense in a
.mdfile, email, logs, or a non-interactive renderer. - Declarative, not executable. No arbitrary JavaScript. The assistant describes UI; the host app decides how to render and handle it.
- Streaming-safe. Partial rendering, progress updates, cancellation, replacement, and stable component identity.
- Stateful, but auditable. Every interaction becomes an explicit event:
change,submit,click,cancel. - Accessible by default. Controls map to native HTML where possible, preserving keyboard and screen-reader expectations.
- Graceful fallback. Unsupported renderers show understandable text, not broken widgets.
Syntax layer: directive blocks
Use Markdown-like directive blocks. Every component gets a type, a stable id, attributes, and a Markdown body.
:::notice {#quota kind=warning}
You are close to your usage limit.
:::Basic interactive controls
Radio group
:::radio {#plan bind=subscription.plan label="Choose a plan" required}
- ( ) Free {value=free}
- (x) Pro {value=pro}
- ( ) Enterprise {value=enterprise}
:::Checkbox
- [ ] Email me a summary {#email-summary bind=notify.email}
- [x] Save this conversation {#save-chat bind=conversation.save}Checkbox group
:::checkgroup {#sources bind=research.sources label="Sources to include"}
- [x] Official docs {value=official}
- [x] Academic papers {value=papers}
- [ ] Blog posts {value=blogs}
- [ ] Forums {value=forums}
:::Select
:::select {#tone bind=draft.tone label="Tone"}
- Professional {value=professional}
- Friendly {value=friendly selected}
- Concise {value=concise}
:::Text input
:::input {#company bind=company.name label="Company name" placeholder="Acme Inc."}
:::Textarea
:::textarea {#notes bind=user.notes label="Additional instructions" rows=4}
Prefer examples in TypeScript.
:::Button
:::button {#run-analysis action=run_analysis kind=primary}
Run analysis
:::Button group
:::actions {#next-steps}
- [Approve](action:approve) {kind=primary}
- [Revise](action:revise)
- [Cancel](action:cancel) {kind=danger}
:::Rich display primitives
Status
:::status {#s1 state=running}
Searching your files…
:::
:::patch {target=#s1}
state: done
body: Found 4 matching files.
:::Progress
:::progress {#p1 value=35 max=100 label="Analyzing documents"}
35% complete
:::
:::patch {target=#p1}
value: 70
:::Card
:::card {#candidate-1 title="Option A" href="https://example.com"}
**Cost:** $24/month
**Best for:** small teams
**Tradeoff:** fewer integrations
:::Tabs
:::tabs {#answer-tabs}
:::tab {label="Summary"}
The short explanation goes here.
:::
:::tab {label="Details"}
The detailed explanation goes here.
:::
:::tab {label="Raw data"}
```json
{"items": 42}
```
:::
:::Accordion
:::accordion {#faq}
:::item {title="Why this recommendation?"}
Because it matches your budget and constraints.
:::
:::item {title="What are the risks?"}
The main risk is vendor lock-in.
:::
:::Why this recommendation?+
Because it matches your budget and constraints.
What are the risks?+
The main risk is vendor lock-in.
Forms
Forms group multiple controls and define a submit action. Submitting emits a single ui.submit event with all bound values. Try it.
:::form {#travel-form action=plan_trip}
:::input {#destination bind=trip.destination label="Destination" required}
:::
:::select {#budget bind=trip.budget label="Budget"}
- Budget {value=budget}
- Mid-range {value=mid selected}
- Luxury {value=luxury}
:::
:::radio {#pace bind=trip.pace label="Travel pace"}
- ( ) Relaxed {value=relaxed}
- (x) Balanced {value=balanced}
- ( ) Packed {value=packed}
:::
:::button {type=submit kind=primary}
Plan trip
:::
:::Streaming model
Plain Markdown streams poorly because later text can change how earlier text parses. An unclosed code fence, table, or list forces a re-render. MarkdownUI separates content streaming from UI patching over a transport-independent event model. Server-Sent Events are one possible carrier.
event: md.open
data: {"message_id":"msg_123","version":"amui-0.1"}
event: md.delta
data: {"append":"I found three options.\n\n"}
event: ui.node
data: {
"id": "plan",
"type": "radio",
"bind": "subscription.plan",
"options": [
{"label": "Free", "value": "free"},
{"label": "Pro", "value": "pro", "selected": true},
{"label": "Enterprise", "value": "enterprise"}
]
}
event: md.close
data: {"message_id":"msg_123"}The rule that makes this safe: streamed text is append-only; component changes happen through explicit patches.
Patch semantics
{ "op": "set", "target": "#p1", "path": "/value", "value": 80 }
{ "op": "replace_body", "target": "#s1", "markdown": "Done." }
{ "op": "disable", "target": "#submit" }
{ "op": "remove", "target": "#spinner" }
{ "op": "append", "target": "#log", "markdown": "\nFetched page 3." }- Every interactive node must have a stable
#id. - Patches may only target existing IDs.
- Patches must not silently alter user-entered values unless explicitly marked with a
reason. - Once a message is committed, later changes appear as new messages or auditable revisions.
State and binding
Each control binds to a state path, like bind=trip.destination or bind=settings.notifications.email. The renderer owns local UI state; the agent receives events and does not mutate the UI invisibly. Recommended events: ui.change, ui.click, ui.submit, ui.cancel, ui.focus, ui.blur, ui.error.
{
"type": "ui.change",
"node_id": "budget",
"bind": "trip.budget",
"old_value": "mid",
"value": "luxury"
}Validation
Controls declare validation rules; errors arrive as a block or a patch. The field below validates live.
:::input {#email bind=user.email label="Email" type=email required}
:::
:::error {for=#email}
Please enter a valid email address.
:::Please enter a valid email address.
Agent actions
Actions are named capabilities, not arbitrary code. Sensitive ones require a user gesture and an explicit confirm.
:::button {#book action=book_flight requires_confirmation=true}
Book flight
:::
:::confirm {#confirm-book action=book_flight}
Book this flight for $420?
:::Book this flight for $420?
No action executes merely because Markdown was rendered.
Security model
MarkdownUI explicitly forbids:
<script>and inline JavaScript handlers- arbitrary iframes and untrusted remote components
- automatic form submission or file upload
- hidden inputs that submit sensitive data
- CSS capable of layout spoofing
Allowed: Markdown text, sanitized links, declared UI components, declared actions, host-approved capabilities, native form controls, and allowlisted media. Every agent-generated component is untrusted until validated by the host.
Accessibility requirements
Every interactive component needs a label, a role (or native equivalent), keyboard support, and focus, disabled, and error states. Preferred mappings:
| MarkdownUI component | Preferred HTML |
|---|---|
| radio | <fieldset> + <legend> + <input type="radio"> |
| checkbox | <input type="checkbox"> |
| select | <select> |
| input | <input> |
| textarea | <textarea> |
| button | <button> |
| progress | <progress> |
| accordion | <details> / <summary> |
For custom-rendered radio groups, follow established keyboard behavior: tab into and out of the group, space selects, arrow keys move among options.
Fallback behavior
Every component defines a plain Markdown fallback, so transcripts stay useful even without an interactive renderer.
:::radio {#plan label="Choose a plan"} Choose a plan
- ( ) Free → - ( ) Free
- (x) Pro - (x) Pro
- ( ) Enterprise - ( ) Enterprise
:::What this isn’t
It isn’t a standard. There are already nine of those, and the good ones have vendors behind them. This is one designer thinking out loud, with a working prototype attached.
It isn’t a replacement for real UI. The moment you need layout, charts, type-safe validation, or live two-way state, a JSON tree or an iframe is the right call, since that is where those formats earn their weight. This is for the long tail of small interactions agents need all the time: a choice, a confirmation, a short form, a status line that updates while it streams.
And it isn’t finished. This is a sketch. If the bet holds, and interfaces really do become readable and streamable, the next step isn’t a bigger spec. It is a small, hardened renderer that emits into something like AG-UI, so the readable layer and the event layer are the same thing.
Prior art
After I sketched this, I found markdown-ui.com by Blueprint Lab. Same name, same idea, and further along: a real open standard with React, Svelte, and Vue implementations. Theirs puts each widget in a fenced code block with a small DSL; this one bets on the markup itself staying readable. If you want something to use today, look at theirs.
Changelog
Every version is tagged in git, so there’s an exact snapshot behind each number.
| Version | Date | Notes |
|---|---|---|
| v0.0.2 | June 24, 2026 | Edited the writing, refined the type and color, added an image and credits. |
| v0.0.1 | June 23, 2026 | Initial sketch. The argument, live demos of each primitive, and an honest map of where it fits. |
Credits
Written by Eli Rousso and Claude Opus 4.8.