DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Teaching Coding Agent to Write XSLT — Building a Domain Skill

XSLT is unforgiving. Small mistakes — a wrong namespace, a version mismatch, an engine-specific function — produce silent failures. AI assistants make the same mistakes without explicit guidance.

The fix isn't a smarter model. It's giving the model a playbook.

Claude Code supports custom skills — markdown files that load into context when a task matches. I built one that encodes real XSLT expertise: five work modes, engine detection, version discipline, and structured output you can debug immediately.


What is a Claude Code skill?

A skill is a markdown file in .claude/skills/<name>/SKILL.md. When Claude detects a matching task, it reads the skill and follows its instructions. Reference files alongside it provide deeper detail on demand.

.claude/skills/xslt/
├── SKILL.md                        # modes, rules, output spec
└── references/
    ├── lml.md                      # Logic Apps Mapping Language
    ├── logicapps-xslt.md           # Logic Apps deployment and artifacts
    ├── saxoncs-quirks.md           # SaxonCS / XSLT 3.0 gotchas
    ├── biztalk-xslt.md             # BizTalk map authoring
    └── migration.md                # version migration guide
Enter fullscreen mode Exit fullscreen mode

The skill built in this post is available at imdj360/dev-skills.


Mode detection

The skill first classifies the task into one of five modes:

Mode Trigger
Generate XML samples, schemas, "write me an XSLT"
Debug Broken XSLT, error message, wrong output
Migrate "Convert to 3.0", "rewrite as 1.0"
Compatibility "Will this run in BizTalk?"
LML .lml file, Data Mapper, "compile to XSLT"

Generate and LML produce an output bundle. Generate writes up to three artifacts (debug config, sample input, stylesheet). LML writes up to four core artifacts (debug config, sample input, LML source, compiled XSLT) — plus schemas and custom functions if the map needs them. The other modes produce artifacts only when needed.


Engine classification

This is where most AI-generated XSLT fails. The skill forces engine classification before any code is written:

  • BizTalk / XslCompiledTransform / msxsl:script → XSLT 1.0, compiled engine
  • Logic Apps (Transform XML action) → XSLT 1.0 by default
  • Saxon / SaxonCS → XSLT 2.0 or 3.0, saxonnet engine

If ambiguous, Claude must ask. It never assumes. This single rule prevents the most common failure: writing XSLT 2.0 features in a file that .NET XslCompiledTransform will choke on.


Version discipline

Never auto-upgrade. If the stylesheet says version="1.0", output stays 1.0. No sneaking in xsl:function, matches(), or xsl:sequence.

For 1.0, the skill enforces:

  • xsl:for-each only — no xsl:for-each-group
  • contains(), substring(), translate() — not matches(), replace()
  • Named templates, not xsl:function

Inline C# for XSLT 1.0

Pure XSLT 1.0 has gaps — no regex, no date formatting. The msxsl:script extension fills them with inline C#:

<msxsl:script language="C#" implements-prefix="fn">
  <![CDATA[
    public string ReformatDate(string isoDate) {
      if (System.DateTime.TryParse(isoDate, out System.DateTime dt))
        return dt.ToString("dd/MM/yyyy");
      return isoDate;
    }
  ]]>
</msxsl:script>
Enter fullscreen mode Exit fullscreen mode

The skill knows the type mappings (XPath numbers → double, node-sets → XPathNodeIterator) and the constraint: msxsl:script runs on .NET XslCompiledTransform only, never Saxon.


Canonical skeletons

The skill provides starting templates so Claude doesn't construct structure from scratch.

XSLT 1.0 + inline C#:

<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:msxsl="urn:schemas-microsoft-com:xslt"
  xmlns:fn="urn:my-functions"
  exclude-result-prefixes="msxsl fn">
  <xsl:output method="xml" indent="yes"/>
  <!-- msxsl:script block, then templates -->
</xsl:stylesheet>
Enter fullscreen mode Exit fullscreen mode

XSLT 3.0 / SaxonCS:

<xsl:stylesheet version="3.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:xs="http://www.w3.org/2001/XMLSchema"
  exclude-result-prefixes="#all"
  expand-text="yes">
  <xsl:output method="xml" indent="yes"/>
  <xsl:mode on-no-match="shallow-skip"/>
  <!-- templates -->
</xsl:stylesheet>
Enter fullscreen mode Exit fullscreen mode

LML-generated XSLT 3.0 uses different conventions (mode="azure.workflow.datamapper", explicit namespace exclusions). The skill tracks both so Claude doesn't confuse them.


Debug taxonomy

For Debug mode, Claude classifies the bug before attempting a fix:

Class Danger
Namespace mismatch — empty output, XPath returns nothing High — exit code 0, looks like success
C# compile error — Roslyn error in msxsl:script Obvious — exit code 1
Template priority conflict — wrong template fires Medium
Version mismatch — works in Saxon, fails in .NET Medium

Namespace mismatch is the most dangerous: the transform succeeds with valid XML structure but every value is empty.


The output bundle

For Generate and LML modes, the skill requires a complete output bundle.

Generate mode writes:

  1. Launch config — appended to .vscode/launch.json
  2. Sample input XML — if none exists for the source schema
  3. Stylesheet — the .xslt file

LML mode writes (order matters):

  1. Source and target schemas — XSD files in Artifacts/Schemas/, if not already present
  2. Launch config — points at the compiled .xslt
  3. Custom extension functions — if the map uses them (must be written before LML compiles)
  4. Sample input XML — if none exists for the source schema
  5. LML file — written last (compile hook fires on this)
  6. Compiled XSLT — generated automatically by the compile hook

A launch config:

{
  "name": "Debug Order2Shipment",
  "type": "xslt",
  "request": "launch",
  "engine": "saxonnet",
  "stylesheet": "${workspaceFolder}/Artifacts/Maps/Order2Shipment.xslt",
  "xml": "${workspaceFolder}/Artifacts/SampleData/Order.xml",
  "output": "${workspaceFolder}/Artifacts/Maps/out/Order2Shipment-out.xml",
  "stopOnEntry": true
}
Enter fullscreen mode Exit fullscreen mode

output is optional — omit it to see results in the Debug Console only. Press F5 and debug with breakpoints — no manual setup.


LML mode: custom functions and gotchas

LML maps can call custom functions in Artifacts/DataMapper/Extensions/Functions/*.xml:

<customfunctions>
  <function name="reformatDate" as="xs:string">
    <param name="dateVal" as="xs:date"/>
    <value-of select="format-date($dateVal, '[D01]/[M01]/[Y0001]')"/>
  </function>
</customfunctions>
Enter fullscreen mode Exit fullscreen mode

The skill documents SDK bugs Claude needs to work around:

  • Zero-parameter functions crash the compilerNullReferenceException, entire XML file silently skipped. Workaround: add a dummy <param>.
  • Multi-step paths in function arguments fail — wrap in xpath().
  • Bare @attr is invalid YAML — use xpath("@lineNum").

What the skill doesn't do

The skill is domain knowledge and rules. It doesn't run anything — that's the job of hooks (Part 2). It doesn't validate XSLT at runtime — that's the debugger.

Its value is preventing mistakes before they happen. Part 2 covers the hooks that catch errors at write time.


The XSLT Debugger extension is on the VS Code Marketplace for macOS and Windows.

Top comments (0)