DEV Community

Cover image for JSX That Outputs Markdown
Roman Dubinin
Roman Dubinin

Posted on • Originally published at romanonthego.dev

JSX That Outputs Markdown

This started because managing agent instruction files as template strings became unbearable. The fix was JSX.

I have about fifteen of them now — Markdown files that tell LLM agents how to behave. An orchestrator, a code implementer, a critic, a planner, a handful of single-purpose grunts. It grew out of experimentation — different persona variants, different tool sets per harness, shared fragments that kept getting copy-pasted between files. Each file defines the agent's role, what tools it has access to, what constraints it follows, how it handles failure. A typical file starts looking something like this:

const forgePrompt = `
# Forge

You are an implementation agent. Write code, tests, migrations.

Axes: trust=assume-broken, solution=converge, risk=block.

## Tools
You have access to:
${tools.map(t => `- \`${t.name}\`${t.description}`).join('\n')}

## Code Rules
- **P0**: No non-null assertions. No \`as\` casts without type guards.
- **P0**: No \`enum\`. Use literal unions.
${strict
  ? '- **P0**: Run \`lsp_diagnostics\` on ALL changed files. Zero errors.'
  : ''}

## Workflow

1. Read task from dispatch header.
2. Run existing tests — establish green baseline.
3. Implement. Tight diffs, reviewable chunks.
${harness === 'opencode'
  ? '4. Use \`task()\` for subtask delegation.'
  : harness === 'copilot'
    ? '4. Use \`runSubagent\` for subtask delegation.'
    : ''}
`
Enter fullscreen mode Exit fullscreen mode

I'm a frontend developer. I write React for a living. JSX was the obvious pattern for this: typed components, props for variants, imports for shared fragments. But React is a UI framework, and I needed a string concatenator. Then I remembered jsxImportSource.

The template string trap

When your agent instructions live inside template literals, your editor treats them as strings. Because they are strings. Everything you rely on — syntax highlighting, type checking, autocomplete, error detection, go-to-definition — stops at the opening backtick. You're writing Markdown inside a JavaScript string inside a TypeScript file, and your IDE gives you nothing.

Not "limited support." Nothing. The headings are strings. The code references are escaped strings inside strings. A broken indentation is invisible until you run the agent and the output is wrong.

I went looking at how other harnesses handle this — to know if someone had already solved it. Not in any harness I looked at.

oh-my-openagent — 40k stars, production harness — builds its orchestrator prompt the same way. 430 lines. Eight XML-structured sections assembled from template strings. The task management block duplicates the same instructions twice for different tool APIs:

function buildTasksSection(useTaskSystem: boolean): string {
  if (useTaskSystem) {
    return `<tasks>
Create tasks before starting any non-trivial work.

Workflow:
1. On receiving request: \`TaskCreate\` with atomic steps.
2. Before each step: \`TaskUpdate(status="in_progress")\`
3. After each step: \`TaskUpdate(status="completed")\` immediately.
</tasks>`;
  }

  return `<tasks>
Create todos before starting any non-trivial work.

Workflow:
1. On receiving request: \`todowrite\` with atomic steps.
2. Before each step: mark \`in_progress\`
3. After each step: mark \`completed\` immediately.
</tasks>`;
}
Enter fullscreen mode Exit fullscreen mode

The escaped backticks are the obvious tell. But two functions returning near-identical strings with no shared abstraction — that's the actual damage. Eight sections concatenated with ${identityBlock}\n${constraintsBlock}\n${intentBlock}.... Type safety is string — every section builder returns a string, the entire prompt is a string, the contract is "it's a string." If a section builder returns malformed Markdown or forgets a closing XML tag, you find out when the agent misbehaves.

Full circle, sort of

Markdown was invented as a lightweight authoring format for HTML — write readable plain text, get structured markup out. Twenty years later, we're writing that Markdown inside template strings inside TypeScript, and all the readability Markdown was supposed to provide is gone.

The fix I landed on: JSX — which is itself a syntax for XML — generating that same Markdown. Markdown to simplify HTML. JSX to simplify Markdown. The lineage is absurd, but it works for a boring reason: the entire ecosystem already knows how to handle JSX.

Why JSX

The obvious fix for template string pain is to stop using template strings — write plain Markdown files, load them at build time. That works until you need conditionals. Different tool sets per harness, strict mode flags, sections that vary by deployment. Once instructions have variants, you need a way to express them, and Markdown doesn't have one. JSX does.

Syntax highlighting works. TypeScript type-checks JSX — wrong prop names, missing required props, mismatched children types are all compile errors. Autocomplete for component props, go-to-definition for component sources, inline documentation on hover. Refactoring tools — rename a component and every usage updates. Import organization. Dead code detection. All of it works because JSX is not a new language. It's a transform layer on top of TypeScript that your toolchain understands.

And LLMs know how to write it. Every model has seen massive amounts of JSX in training data — React components, prop patterns, conditional rendering, map over arrays. When you ask an agent to modify a jsx-md component, it doesn't need a tutorial.

JSX is not React. It's a transform specification. The compiler sees <H2>Title</H2> and rewrites it to jsx(H2, { children: "Title" }). That's it. The jsxImportSource option in tsconfig.json says where that jsx factory comes from — point it at any package that exports the right function, and JSX works with no React anywhere in the dependency graph.

JSX-to-Markdown as an approach is not new. dbartholomae/jsx-md has been around since 2019; eyelly-wu/jsx-to-md is more recent. Both are built for documentation generation — READMEs, changelogs — and work well for that. dbartholomae predates jsxImportSource and uses file-level pragma comments instead; its render() returns a Promise — reasonable for writing files to disk, an awkward fit for instructions assembled at call time.

The agent instruction use case needs two things neither provides. The harness and strict variants in the opening example are shallow, but fifteen agents sharing fragments turns that into its own copy-paste problem: every shared component grows a harness prop it doesn't use except to pass it down. Context solves this — set the value at the root, read it anywhere in the tree. The other gap is XML intrinsics: Anthropic recommends structuring Claude prompts with <instructions>, <context>, <examples> as literal XML blocks. Any lowercase tag in @theseus.run/jsx-md renders as XML — no imports, no registration.

@theseus.run/jsx-md is a JSX runtime that outputs Markdown strings. H2 is a plain function — takes props, returns "## Title\n\n". render() walks the VNode tree synchronously and concatenates. No virtual DOM, no reconciler, no fiber, no hooks. Same input, same string, every time. All the testing patterns you'd use for React components transfer — snapshots catch prompt regressions, unit tests verify conditional branches, you can assert on rendered output of any component in isolation.

Md is an escape hatch for raw Markdown passthrough — <Md>{someString}</Md> renders the string verbatim, no transformation.

The same agent prompt from the opening, rewritten:

const ForgePrompt = ({ tools, strict, harness }: Props) => (
  <>
    <H1>Forge</H1>
    <P>You are an implementation agent. Write code, tests, migrations.</P>
    <P>Axes: trust=assume-broken, solution=converge, risk=block.</P>

    <H2>Tools</H2>
    <P>You have access to:</P>
    <Ul>
      {tools.map(t => (
        <Li><Code>{t.name}</Code>{t.description}</Li>
      ))}
    </Ul>

    <H2>Code Rules</H2>
    <Ul>
      <Li><Bold>P0</Bold>: No non-null assertions. No <Code>as</Code> casts without type guards.</Li>
      <Li><Bold>P0</Bold>: No <Code>enum</Code>. Use literal unions.</Li>
      {strict && (
        <Li><Bold>P0</Bold>: Run <Code>lsp_diagnostics</Code> on ALL changed files. Zero errors.</Li>
      )}
    </Ul>

    <H2>Workflow</H2>
    <Ol>
      <Li>Read task from dispatch header.</Li>
      <Li>Run existing tests — establish green baseline.</Li>
      <Li>Implement. Tight diffs, reviewable chunks.</Li>
      {harness === 'opencode' && (
        <Li>Use <Code>task()</Code> for subtask delegation.</Li>
      )}
      {harness === 'copilot' && (
        <Li>Use <Code>runSubagent</Code> for subtask delegation.</Li>
      )}
    </Ol>
  </>
)
Enter fullscreen mode Exit fullscreen mode

No escaped backticks. Syntax highlighting. Type-checked props. The harness conditional is a JSX expression — the editor shows you which branch applies. Shared sections are components you import — the constraints, tool lists, and workflow steps that were getting copy-pasted between files are just props now. The nesting depth for lists is tracked automatically — write <Ul> inside <Li> and the renderer handles the indentation. You never count spaces.

Zero runtime dependencies. render() is synchronous, deterministic, and returns a plain string. String children pass through verbatim — no escaping. Bun resolves the TypeScript source directly; Node.js ≥18 and bundlers use the compiled ESM output, the exports map handles it without configuration.

The ForgePrompt above still passes harness as a prop. Context removes it from the tree entirely:

const HarnessCtx = createContext<'opencode' | 'copilot'>('opencode')

const StepsSection = () => {
  const harness = useContext(HarnessCtx)
  return (
    <Ol>
      <Li>Implement. Tight diffs, reviewable chunks.</Li>
      {harness === 'opencode' && <Li>Use <Code>task()</Code> for subtask delegation.</Li>}
    </Ol>
  )
}

// Root wires it once — no prop threading:
render(
  <HarnessCtx.Provider value="opencode">
    <ForgePrompt tools={tools} strict={true} />
  </HarnessCtx.Provider>
)
Enter fullscreen mode Exit fullscreen mode

Components read from context directly. The harness prop disappears from everything below the root.

XML intrinsics

Anthropic recommends XML tags to structure Claude prompts — <instructions>, <context>, <examples> around each content type. If you work with Claude, you're probably already doing this.

In @theseus.run/jsx-md, any lowercase JSX tag is an XML intrinsic element. No imports, no registration — it's built into the type system's catch-all:

const ReviewerPrompt = ({ repo, examples }: Props) => (
  <>
    <context>
      <P>Repository: {repo}. Language: TypeScript. Package manager: bun.</P>
    </context>

    <instructions>
      <H2>Role</H2>
      <P>You are a precise code reviewer. Find bugs, not style issues.</P>

      <H2>Rules</H2>
      <Ul>
        <Li>Flag <Bold>P0</Bold> issues first — do not bury them.</Li>
        <Li>One finding per comment. No compound observations.</Li>
        <Li>Use <Code>inline code</Code> when referencing identifiers.</Li>
      </Ul>
    </instructions>

    {examples.length > 0 && (
      <examples>
        {examples.map((ex, i) => (
          <example index={i + 1}>
            <Md>{ex}</Md>
          </example>
        ))}
      </examples>
    )}
  </>
)
Enter fullscreen mode Exit fullscreen mode

Attributes are typed — index={1} serializes to index="1". Boolean true attributes render bare, false/null/undefined are omitted. Empty tags self-close. The Anthropic-recommended structure falls out of the JSX type system for free.

Agent skill

The package ships a skill — OpenCode, Cursor, Copilot, Claude Code:

npx skills add https://github.com/theseus-run/theseus/tree/master/packages/jsx-md
Enter fullscreen mode Exit fullscreen mode

The agent knows the primitives, Context API, XML intrinsics, and authoring rules.


@theseus.run/jsx-md. MIT, zero dependencies. Bun, Node.js ≥18, any bundler. Source on GitHub. If your agent instructions have outgrown template strings, this is the fix I actually use.

Top comments (0)