mdwire
English | 한국어
Send LLM-generated Markdown to chat channels without it breaking. Docs, API reference and a live demo: mdwire.minjun.dev.
Agents emit Markdown. Chat channels don't take it as is — each has its own subset, its own escaping rules, and its own length limit. Existing converters assume the input is well-formed CommonMark and target one channel at a time. Neither assumption holds for agent output.
Status: v0.1.10. Normalizing, rendering, splitting and streaming work for six
targets — Telegram HTML, Slack markdown_text, GitHub comments (GFM), Notion pages, plain text,
and HTML for the browser — from a Rust core, a CLI,
an npm package (WASM), and a Go port. See SPEC.md for what is in v0.1 and what was
deliberately deferred. SPEC.md and DESIGN.md are written in Korean.
What it does
LLM markdown → normalize → render for channel → split safely → send
- Normalize. Agent-written Markdown may not render correctly through a standard
Markdown converter. Typical cases: unpaired
**, emphasis that spans a line break in wrapped prose, unclosed code fences. Repair before rendering. - Render. Emit the syntax the channel actually accepts. Telegram HTML allows nine
tags; Slack
markdown_texttakes standard Markdown directly. GitHub takes it too, but reads a lone~as strikethrough and<T>as an HTML tag — so a~or<meant as a character goes out escaped (\~,\<), and emphasis that GFM would not close (**(a)**followed directly by a Korean particle) goes out as<strong>. Notion renders that bold as is but shows inline HTML as text, sonotion-markdownstrips the tags and escapes a literal*or\instead. For the browser,htmlrenders blocks as tags too and is safe to set asinnerHTML: text is escaped, inline tags from the source keep no attributes, and onlyhttp(s)/mailtolinks become<a>— line breaks, images and allowed schemes are options. While streaming, the accumulated output pluspreview()(orcloseOpen()) is always balanced HTML. - Split. Respect the channel's limit — and never cut through markup. This also
covers streaming: a chunk boundary must not land inside
**bold**.
The pipeline has one option. Repair report: how many times the normalizer stepped in — unclosed emphasis, unclosed fence, unpaired backticks, dropped markers — and what it rewrote for the channel — escaped characters, tags for emphasis, stripped HTML, bullets, tables, converted markers — so you can log how often the model breaks its own formatting and see what a channel changes before you adopt it.
Use it
| | |
# --report prints what the normalizer fixed and rewrote (unclosed emphasis, escaped `~`,
# stripped tags, …) to stderr as one JSON line.
|
let parts = render;
let mut s = new;
s.push_into; // no allocation per chunk
s.finish_into; // flush, closing anything left open
import from "@minjun0219/mdwire"; // npm — bundlers, Node, Bun
const parts = ;
const = ;
// A channel that rewrites the whole message (Telegram edit): send acc plus the preview —
// what is still held (open bold, table rows, a code span) drawn as if the input ended here.
// Keep acc itself untouched. After finish, skip the last edit if nothing changed.
const s = ;
let acc = "";
acc += s.;
if await ;
// An append-only channel (Slack appendStream): send each piece as is — never preview.
const t = ;
await ;
In React, @minjun0219/mdwire/react builds elements with createElement — no
innerHTML. Escaping, the tag set and link schemes are decided once, in the core's html
channel; you choose which component draws each tag. @minjun0219/mdwire/events gives the
same output as an open / text / close event list for other frameworks.
import from "@minjun0219/mdwire/react";
// a finished answer
const = ; // streaming: push(token), finish()
The hook draws held content early by default; useMarkdownStream({ eager: false }) shows only
what is final, and onSettled(html, revised) tells you whether the finished text differs from
the last frame.
examples/react-streaming streams one answer into react-markdown, Streamdown,
mdwire in front of Streamdown, and mdwire side by side. Measured numbers are in DESIGN.md.
Append-only contract. What push returns is final — a later chunk never rewrites it —
and finish only appends the tail. So the pieces concatenated equal a one-shot render,
whatever the chunk size (unless the document is long enough to be split into parts).
This is tested on the corpus, by fuzzing, and by mdwire-check --scan <dir>, which streams
every file one character and 64 characters at a time and reports any divergence. See
SPEC.md §8.2.
The streamer holds back only what it must: a prefix it cannot classify yet, a marker run at the end of a chunk, and the inside of an emphasis that has not closed. A whole paragraph is never held — a renderer that waits for a newline is not streaming.
Why another one
We found three gaps in existing tools:
-
Emphasis spanning lines is common. In a sample of 60 agent-generated documents, 44 contained emphasis spanning a line break. A regex-based converter mispaired those into inverted emphasis ranges — and the channel returned HTTP 200, so nothing caught it.
-
Common Korean notation collides with Markdown.
- In
**설정(config)**을or**52%**다, the bold ends in a symbol and a particle follows right after. Under CommonMark's rules the bold does not close and the**shows as text. GitHub and browser renderers follow those rules. - In
약 ~40km, 5~6월, tildes mark an approximation and a range. With two of them in one paragraph, GitHub (GFM), which reads a single~as strikethrough, pairs them and strikes everything in between (40km, 5).
The two converters we compared disagree on Korean-adjacent emphasis (one pads it with U+200B, the other leaves it alone), and neither checked the channel. mdwire measured each channel: on GitHub it writes just that bold as
<strong>and escapes a literal~as\~. - In
-
Chat-channel converters ignore streaming. Some browser renderers, like Streamdown, patch unclosed syntax while tokens stream in. The Telegram and Slack converters we looked at all take the whole document and convert it in one pass. When tokens arrive incrementally, markup splits across chunk boundaries.
Design
- No dependencies in the core. Not a purity stance — batch parsers are structurally
wrong for streaming. See
DESIGN.md. - Rust core, many front ends. WASM for npm, a single static binary for the CLI.
The CLI matters most: any agent in any language can pipe through it with no bindings.
A Go port lives in
go/(stdlib only) and is held to the same corpus — and to the Rust core itself: random inputs and real documents must render identically. - The test corpus is a first-class artifact.
corpus/holds input → expected output per channel. A port in another language is correct when it passes the corpus. This is how consistency survives more than one implementation.
Installing
From the registries:
Every release also carries its own artifacts, if you would rather not go through a registry:
# npm package (works under a bundler and in plain Node)
# CLI binary — pick your platform
| | |
The release notes list a SHA-256 for every artifact — verify the download against it when installing by URL.
Or from source: cargo install --path crates/mdwire-cli.
Go, as a library or a CLI with the same flags:
parts := mdwire.Render(input, mdwire.TelegramHTML)
s := mdwire.NewStreamer(mdwire.SlackMarkdown)
s.PushTo(chunk, &out) // no allocation per chunk
s.FinishTo(&out)
out := mdwire.RenderWith(input, mdwire.SlackMarkdown, mdwire.Options)
log.Printf("%+v", out.Repairs)
Building
The wasm binary is 111 KB (release build, after wasm-opt). The package carries two builds and
picks by exports condition: node gets a CommonJS build that loads the wasm from disk,
everything else gets the ESM bundler build. The script writes the root package.json
itself: the crate has to stay mdwire-wasm because the core's library is already named
mdwire, and wasm-pack takes the npm name from the crate.
mdwire-check scores any implementation that reads stdin and writes stdout, so a port in
another language can be measured with the same yardstick:
Releasing
Nobody edits the version by hand. When a merge to main changes what ships (the core, CLI,
WASM or Go sources, manifests, npm packaging), a bot keeps a release: X.Y.Z pull request open; merging it tags vX.Y.Z and go/vX.Y.Z and publishes
the release with its artifacts. See AGENTS.md.
License
MIT