Extensible Markdown
The AI era has made Markdown one of its main interface formats.
It was already a natural choice for documentation, knowledge bases, and web publishing. Large language models gave it another job: Markdown has become the de facto format for prompts, responses, generated reports, and context passed among people, agents, and tools.
The reasons are practical. Markdown is compact and readable before rendering. It is easy to generate and transform into HTML, PDF, or structured text. It is one of the few formats that works equally well as source code, published content, and a conversational medium.
The limits are clear too. Markdown has no native syntax for interactive maps, diagrams, image comparisons, live data, media galleries, or other application-specific controls. Even responsive images need srcset, sizes, and loading hints that the basic image syntax cannot express.
The problem is how to extend Markdown without losing the qualities that made it useful.
Start with the platform
Most Markdown parsers can preserve raw HTML, so another syntax may be unnecessary. The exact behavior depends on the parser and its security configuration, but HTML already provides a capable extension layer in a controlled publishing pipeline.
When Markdown's image syntax falls short, the web platform's <picture> element can handle the job without a framework component:
<picture>
<source srcset="diagram.avif" type="image/avif">
<source srcset="diagram.webp" type="image/webp">
<img
src="diagram.png"
srcset="diagram-small.png 480w, diagram.png 960w"
sizes="(max-width: 600px) 100vw, 960px"
width="960"
height="540"
loading="lazy"
alt="The document flows through a parser, renderer, and component layer">
</picture>
The same approach works elsewhere. Native <video> supports multiple sources and captions. <details> with <summary> provides an accessible disclosure widget without JavaScript, while <figure> and <figcaption> connect an illustration with its explanation.
The first rule of extensible Markdown follows:
Use standard Markdown when it is sufficient, and standard HTML when the platform has already solved the problem.
A custom component is worth adding only when Markdown and HTML cannot do the job.
What should an extension preserve?
Adding JSX or arbitrary JavaScript to a document is easy. Keeping that document useful outside one application is harder.
In my view, a good Markdown extension should preserve seven properties:
- A person or language model can read the raw document without executing it.
- If the interactive layer is unavailable, fallback content still gives the reader information or a path to it.
- The document declares what a component represents instead of describing the operations used to construct it.
- The content contract works outside React and is not limited to a browser or server.
- Opening a document does not grant its author arbitrary code execution.
- If an uncontrolled parser ignores or removes custom tags, the inner Markdown remains valid and informative.
- Wrapper tags and inner-data schemas have stable semantics that renderers, agents, and other tools can treat as a public API.
These properties matter even more when AI agents read and write documents. Explicit structure helps an agent. Reconstructing meaning from framework internals does not.
MDX
MDX is the best-known attempt to extend Markdown. It combines Markdown with JSX, so authors can import components, evaluate JavaScript expressions, and compose a document as part of an application.
Its main advantage is direct access to a React component library. This can be a convenient shortcut in a React product where developers write all the content. The application already has the runtime, compiler, components, and conventions that MDX expects.
That convenience does not make MDX a general solution for extensible Markdown. Outside the React ecosystem, I see no compelling reason to add JSX compilation and its runtime model. Custom Elements and JSDA can express the same capabilities while keeping the document based on web standards.
The choice involves more than syntax. Content should remain declarative, application logic belongs in modules, and interactive behavior belongs in components. The renderer decides which implementations are available and trusted.
MDX folds these layers into an application-specific source format. Custom tags with JSDA keep them separate without giving up capability.
Even within React, MDX is a pragmatic integration choice rather than an architectural advantage. Outside React, the trade-off makes little sense. It adds framework coupling for features that HTML, Custom Elements, ESM, and ordinary JavaScript already provide.
Custom Elements as Markdown wrappers
The Custom Elements standard offers another option. A custom tag defines a semantic boundary, and the browser upgrades it when an implementation becomes available.
What the tag surrounds matters. A Custom Element in Markdown can wrap ordinary Markdown instead of hand-written HTML. The Markdown remains the durable content layer.
Consider an image comparison:
<image-compare>
### Before

### After

</image-compare>
In a compatible pipeline, the inner Markdown renders inside <image-compare>, and the component turns the images into a draggable comparison. If another parser ignores or removes the wrapper tags, the headings and images still form a complete, readable Markdown fragment.
The wrapper adds semantic scope and behavior. The underlying information stays in Markdown.
Initialization data can follow the same rule. The basic format is a fenced JSON block inside the custom tag:
<interactive-map>
```json
{
"center": {
"lat": 51.5072,
"lon": -0.1276
},
"zoom": 12,
"label": "Central London"
}
```
[Open Central London on a map](https://www.openstreetmap.org/#map=12/51.5072/-0.1276)
</interactive-map>
After conversion to HTML, the component receives a normal <pre><code class="language-json">...</code></pre> element. The initialization logic can stay small:
const source = this.querySelector('code.language-json')?.textContent;
const config = source ? JSON.parse(source) : {};
The browser can use config to initialize a full map through the element's native connectedCallback() lifecycle. A server implementation can read the same block and render a static map. An AI agent sees explicit data instead of opaque component properties.
If an uncontrolled processor removes the <interactive-map> tags, the JSON configuration and fallback link remain visible. Removing the extension mechanism does not remove the data.
A simple convention follows:
- Use a fenced JSON block for essential structured initialization data.
- Put human-readable content and fallbacks in ordinary inner Markdown.
- Reserve attributes for component-specific hints that are safe to lose, such as a temporary presentation or interaction mode.
- Put any information needed to understand or reconstruct the content in the inner Markdown, where removing an attribute cannot erase it.
This is more verbose than hiding everything behind a JSX property, but the extra text has a purpose. People, parsers, and agents can still see the complete contract when the wrapper is unavailable.
A custom tag is a public API
Custom Elements provide a mechanism. The architecture still needs care, because a document full of undocumented tags can be as proprietary as one full of framework components.
To keep the format portable, extension tags should be treated as public APIs:
- Give each tag one clear semantic responsibility.
- Keep its name and inner JSON schema stable.
- Validate parsed configuration instead of trusting it.
- Preserve inner Markdown as the source of truth rather than replacing it unconditionally.
- Reserve attributes for optional component behavior that is safe to lose.
- Make network access and other side effects predictable.
- Give interactive controls accessible names and keyboard behavior.
- Sanitize untrusted HTML, SVG, URLs, and structured data before inserting or executing anything.
- Define what happens when the implementation is missing or fails to load.
This differs from allowing arbitrary JavaScript inside the document. The renderer decides which components to trust and registers their implementations. A document may request a known capability, but the request does not grant permission to execute arbitrary code.
That boundary also suits content produced by an AI agent. The agent can safely generate a known <interactive-map> contract, while the application validates it and decides how to render it.
JSDA: generate context without inventing a document language
Custom Elements handle presentation and interaction. Documents assembled from files, APIs, databases, or computed data present a separate problem.
JavaScript Distributed Assets (JSDA) handles that generation step. JSDA treats a standard ESM module as an endpoint that produces a text asset. For example, a module named build-report.md.js exports the Markdown that becomes build-report.md.
import getBundleMetrics from './getBundleMetrics.js';
const metrics = await getBundleMetrics();
const kb = (bytes) => Number((bytes / 1024).toFixed(1));
const fence = '```';
const data = {
javascriptKB: kb(metrics.js),
cssKB: kb(metrics.css),
totalKB: kb(metrics.total),
};
export default /*md*/ `
## Build report
<bundle-chart>
${fence}json
${JSON.stringify(data, null, 2)}
${fence}
- JavaScript: ${data.javascriptKB} KB
- CSS: ${data.cssKB} KB
- Total: ${data.totalKB} KB
</bundle-chart>
`;
The data in this example can come from any asynchronous JavaScript source. Its output is still ordinary Markdown inside a semantic wrapper. If the wrapper disappears, the JSON block remains available and the Markdown list still describes the chart. Once <bundle-chart> is registered, the browser can enhance the block. The agent and component read the same explicit data, and the setup needs no template language beyond ESM and template literals.
Extensible Markdown does not require JSDA. A hand-written .md file works perfectly well. JSDA adds algorithmic generation when static authorship is no longer enough.
Our JSDA Kit implements this approach for static generation, server rendering, and dynamic output. This website uses it, though the underlying idea does not depend on a particular toolkit.
One contract across server and browser
A custom tag can share an implementation across server-generated and client-rendered content. Symbiote.js is our isomorphic library for web components. With isoMode = true, a component uses the appropriate lifecycle for its environment. When the server has rendered its markup, the browser attaches behavior to the existing structure without a separate diffing stage. A component that arrives empty on the client renders normally.
Isomorphism cannot make browser-only and server-only APIs interchangeable. Runtime-specific dependencies still need explicit boundaries, often through a dynamic import inside try/catch. It does provide a shared component contract, template model, and state model across both environments.
The Markdown document does not need to know where enhancement happens. It declares <bundle-chart> or <interactive-map> around a self-contained payload, and the rendering system chooses the appropriate implementation.
From readable structure to agent tools
Semantic HTML and Custom Elements help an AI agent understand what a page contains. Permissions and available actions need a separate contract.
The document exposes nouns and state through headings, links, inner data blocks, and semantic wrapper tags. A tool protocol exposes verbs and actions, such as changing a map viewport, selecting a dataset, or exporting a report.
Symbiote.js supports WebMCP, which lets a component expose structured tools to an agent. The component can provide a named action with a schema and predictable result. The agent no longer has to find a visually positioned button and simulate a click.
The layers line up:
Markdown section -> semantic custom element -> component state and UI -> agent-callable tools
People, browsers, server renderers, and agents encounter different layers of one contract instead of four unrelated representations.
WebMCP is still experimental, and its APIs may change. The architectural point does not depend on its current implementation: content structure and action interfaces should reinforce each other.
Know where the model stops
Markdown mixed with HTML is not universally portable. Some renderers disable raw HTML. Some remove unknown opening and closing tags while preserving their contents, which is the ideal fallback for this pattern. Others escape the tags, treat the whole region as a raw HTML block, or remove the complete subtree. Parsers also differ in how they handle Markdown inside an HTML element. Messaging apps, email clients, and hosted publishing platforms may flatten the component or reject it entirely.
These constraints do not invalidate the approach, but they define its scope. Extensible Markdown works best when you control the rendering pipeline or can publish an explicit parser profile that processes Markdown inside custom tags. For less predictable destinations, deleting only the wrapper lines should leave valid standalone Markdown.
That is why the inner Markdown matters. Without <image-compare>, two labeled images remain. Removing <interactive-map> leaves its JSON data and fallback link. Removing <bundle-chart> leaves its data and readable summary.
Progressive enhancement remains a browser compatibility technique here, and it also keeps content portable.
The extension ladder
Extensibility does not require making every Markdown document executable. Use the least powerful layer that solves the problem:
- Use Markdown for ordinary document structure.
- Use native HTML for capabilities the platform already provides.
- Wrap Markdown in Custom Elements for domain-specific semantics and progressive enhancement.
- Add ESM and JSDA when the document needs algorithmic generation.
- Add WebMCP-style tool contracts when agents need to act as well as read.
Each step preserves the layers below it. A plain document can grow into a rich web experience, render on either side of the network, and expose structured capabilities to AI agents without becoming a framework-specific application.
Markdown stays the source of truth, with standard HTML and replaceable components around it. Complexity belongs only where it solves a real problem.