md-humanizer is a presentation layer for AI responses. It keeps Markdown and structured response metadata separate from the visual treatment, so the same AI output can appear as a readable document, a compact assistant message, or a handwritten sketch.
npm install md-humanizerimport { HumanizedMessage } from "md-humanizer";
import "md-humanizer/styles.css";
export function AssistantMessage({ content }: { content: string }) {
return <HumanizedMessage content={content} />;
}HumanizedMessage is an alias for HumanizedMarkdown. Existing Markdown works immediately, including GFM tables, task lists, links, fenced code, and partial streaming content.
The optional md-humanizer/ai-sdk entry point accepts the UIMessage objects returned by useChat from @ai-sdk/react. It reads the message parts array, humanizes text parts while they stream, and can map source-url and source-document parts into the renderer's source list.
Install the AI SDK packages in the application that uses this adapter:
npm install ai @ai-sdk/reactThen render assistant messages directly:
import { useChat } from "@ai-sdk/react";
import { HumanizedAIMessage } from "md-humanizer/ai-sdk";
import "md-humanizer/styles.css";
export function ChatMessages() {
const { messages, status } = useChat();
const lastMessageId = messages.at(-1)?.id;
return messages.map((message) =>
message.role === "assistant" ? (
<HumanizedAIMessage
key={message.id}
message={message}
streaming={message.id === lastMessageId && status === "streaming"}
preset="sketch"
showSources
/>
) : (
<div key={message.id}>
{message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")}
</div>
),
);
}The adapter intentionally does not invent UI for tool calls, reasoning, files, or custom data parts. Keep those in the host application, or render them with the optional escape hatch:
<HumanizedAIMessage
message={message}
renderPart={(part) => {
if (part.type === "data-weather") {
return <WeatherCard data={part.data} />;
}
return null;
}}
/>On the server, use the normal AI SDK UI message stream response. The renderer does not require a special endpoint:
const result = streamText({
model,
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();The adapter is available from md-humanizer/ai-sdk so the base renderer does not force AI SDK onto applications that do not use it. It also exports getAIMessageText and toHumanizedDocument for custom message layouts.
The renderer can only humanize syntax that the model emits. Import the maintained prompt contract instead of copying formatting instructions into every application:
import { HUMANIZER_SYSTEM_PROMPT } from "md-humanizer/prompt";
const result = streamText({
model,
system: `${APPLICATION_SYSTEM_PROMPT}\n\n${HUMANIZER_SYSTEM_PROMPT}`,
messages: await convertToModelMessages(messages),
});composeHumanizerSystemPrompt(APPLICATION_SYSTEM_PROMPT) is available when
you prefer a helper. The full portable contract is documented in
docs/humanizer-format.md, with copyable
instructions for coding agents in agent-instructions/.
<HumanizedMarkdown content={answer} preset="reading" />
<HumanizedMarkdown content={answer} preset="sketch" />
<HumanizedMarkdown content={answer} preset="compact" />readingis the accessible default for longer answers.sketchuses the handwritten visual language from the product direction.compactis designed for sidebars, coding assistants, and narrow panels.
The old theme="paper" | "ink" | "quiet" API remains supported as a compatibility alias.
The renderer accepts a plain string or a provider-neutral document object:
<HumanizedMessage
preset="sketch"
content={{
markdown: "The migration is **ready**.",
status: "complete",
blocks: [
{ type: "summary", title: "In short", content: "The core path is ready." },
{ type: "steps", title: "Next steps", items: ["Ship it", "Watch errors"] },
],
sources: [
{
id: "docs",
title: "Documentation",
url: "https://example.com/docs",
domain: "example.com",
},
],
}}
/>Supported block types are summary, callout, tip, warning, decision, and steps. Use renderBlock when an application needs to replace the default presentation.
The package intentionally renders no product controls or response chrome. There is no built-in Copy button, raw-Markdown toggle, retry action, share action, or response background; the host AI app owns those controls.
<HumanizedMarkdown
content={answer}
settings={{
fontScale: 1.1,
lineHeight: "spacious",
contrast: "high",
motion: "reduced",
}}
/>Named font-size variants scale the complete presentation rhythm, including text, headings, spacing, and chart labels. small is 0.85x, medium is 1x, and large is intentionally 3x:
<HumanizedMarkdown content={answer} settings={{ fontSize: "large" }} />For an in-between value, use fontScale from 0.5 to 4. If both are provided, the named fontSize wins.
Sources are opt-in content as well:
<HumanizedMarkdown content={answer} showSources />Set streaming on string content, or use status: "streaming" on a document. Streaming disables decorative chart motion and exposes aria-busy on the response region.
The model can emit portable inline syntax:
Five years side by side — ==NVIDIA== is up ^^+874%^^.Or the host app can pass a sidecar annotation result without changing the model's Markdown:
<HumanizedMarkdown
content={answer}
annotations={[
{ type: "highlight", text: "NVIDIA" },
{ type: "circle", text: "+874%" },
]}
/>Bold Markdown receives a subtle emphasis treatment automatically. Precise highlights and circles should come from structured model output, a tool result, or application policy rather than a renderer guessing at factual importance.
```human-chart
{
"type": "line",
"title": "Five-year performance",
"description": "NVIDIA rises from 0 to 874 percent while AMD rises from 0 to 453 percent.",
"x": ["2022", "2023", "2024", "2025", "2026"],
"series": [
{ "label": "NVIDIA", "color": "#239a60", "values": [0, 80, 280, 540, 874] },
{ "label": "AMD", "color": "#3776d7", "values": [0, 55, 100, 180, 453] }
]
}
```Charts render as inline SVG, expose a text summary, and can include a keyboard-accessible data table:
<HumanizedMarkdown content={answer} showChartData />Invalid chart payloads remain visible as code instead of silently disappearing.
For a convenient runtime loader:
<HumanizedMarkdown
content={answer}
preset="sketch"
googleFont={{
family: "Just Another Hand",
weights: [400],
subsets: ["latin"],
display: "swap",
}}
/>The shorthand is also supported:
<HumanizedMarkdown
content={answer}
preset="sketch"
googleFont="Just Another Hand"
/>The shorthand both loads the Google Fonts CSS2 stylesheet and assigns Just Another Hand to the renderer. This font currently exposes weight 400, so no weight option is necessary for the shorthand. The runtime request is intentionally explicit because it has network, privacy, CSP, and layout-shift implications.
For Next.js, use next/font/google for build-time self-hosting and pass the generated family into font:
import { Just_Another_Hand } from "next/font/google";
const justAnotherHand = Just_Another_Hand({ subsets: ["latin"], weight: "400" });
<HumanizedMarkdown
content={answer}
className={justAnotherHand.className}
font={justAnotherHand.style.fontFamily}
/>;The package also exports GoogleFont, buildGoogleFontUrl, and cssFontFamily for app-level control.
.my-ai-chat .mdh {
--mdh-highlight: rgba(255, 210, 70, 0.55);
--mdh-ink: #202020;
--mdh-link: #245da8;
}Use components to customize Markdown elements and renderBlock to customize structured blocks. The renderer sanitizes embedded HTML and keeps code, links, and data semantically represented.
npm install
npm run check