DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Teaching Coding Agent to Write XSLT — The Hook in Action

Parts 1 and 2 explained the skill and the hook setup. This part shows it actually running — a real session, real prompts, real hook output. No theory.

We'll build the same Order-to-Shipment transformation three ways — XSLT 1.0 with inline C#, XSLT 2.0 with Saxon, and LML compiled to XSLT 3.0 — and watch the hook verify each one, catch bugs, and drive Claude through a multi-step fix cycle. Everything below is from a single session.


Step 1: XSLT 1.0 with inline C

The prompt

Create an XSLT 1.0 transformation (compatible with .NET XslCompiledTransform)
that maps an Order to a Shipment. Use the existing Order.xml as input reference
and target the ShipmentOutput.xsd schema.

Requirements:
- Map OrderID → ShipmentRef with a "SHP-" prefix (use inline C# via msxsl:script)
- Map OrderDate → ShipDate, reformatted from yyyy-MM-dd to dd/MM/yyyy (inline C#)
- Map CustomerName → Recipient
- Generate an ExecutionDate using inline C# that returns DateTime.UtcNow
- Loop over each Item → Line, mapping ProductName → Product and Quantity → Quantity
- Filter out items where Quantity < 1

Use the Order namespace `http://schemas.contoso.com/Order`.
Enter fullscreen mode Exit fullscreen mode

What Claude reads first

Before writing anything, Claude reads the source and target schemas to understand both structures. This is the skill's Generate mode — it requires understanding the data shapes before choosing XPath expressions.

SourceOrder.xml with namespace http://schemas.contoso.com/Order

TargetShipmentOutput.xsd, no namespace:

Claude also reads the existing .vscode/launch.json to know where to append the new debug configuration. Now it writes.

The write sequence

The skill enforces a specific write order: launch config first, stylesheet last. This ensures the debug configuration is in place before the hook fires on the .xslt write.

File 1: .vscode/launch.json — appends a new configuration:

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

Engine is compiled — the .NET XslCompiledTransform engine that supports msxsl:script inline C#. No hook fires on this file.

File 2: Artifacts/Maps/Order2Shipment_20260327.xslt — the stylesheet with three C# helpers in a msxsl:script block:

  • PrefixShipmentRef(string orderId) — prepends "SHP-"
  • ReformatDate(string isoDate) — parses yyyy-MM-dd, returns dd/MM/yyyy
  • ExecutionDate() — returns DateTime.UtcNow as yyyy-MM-ddTHH:mm:ssZ

The hook fires. While Claude is still in the same response turn, the XSLT Debugger API compiles the inline C# via Roslyn, runs the transform against Order.xml, and returns the result:

ClaudeCodePrompt1

That's the clean path. Before moving to XSLT 2.0, let's see what happens when this same map has bugs.


What the hook catches: two classes of failure

Silent data loss — the namespace bug

XSLT's most dangerous bugs produce structurally correct output with silently missing data. No error, no warning — just empty elements.

Claude drops the ord: namespace prefix from one XPath:

<!-- Bug: missing namespace prefix -->
<Recipient>
  <xsl:value-of select="Customer/CustomerName"/>
</Recipient>
Enter fullscreen mode Exit fullscreen mode

The hook fires:

<Recipient></Recipient>
Enter fullscreen mode Exit fullscreen mode

Exit code 0. The transform "succeeded". But <Recipient> is empty. Without the hook, this ships to production and nobody notices until a downstream system complains.

Claude sees the empty element, identifies the namespace mismatch (the skill's #1 debug pattern), and edits the file. The hook fires again on the Edit — not just on the initial Write. Every change to an .xslt file triggers a fresh transform:

<Recipient>John Doe</Recipient>
Enter fullscreen mode Exit fullscreen mode

Fixed. Two hook invocations, one fix.

Hard compile failure — the C# syntax error

FixIssueviaHook

Exit code 1. No XML output at all — Roslyn rejects the C# before the XSLT engine even starts. Claude sees the error with exact line and column, adds the semicolon, hook fires again, clean output.

These are two fundamentally different failure modes:

Failure What happens Exit code Catchable without hook?
Namespace mismatch Valid XML, empty values 0 No — CI pipelines see "success"
C# compile error No output, error message 1 Yes — but only if you run it

The namespace bug is why the hook matters most. It catches the failures that look like success.


Step 2: XSLT 2.0 with Saxon

Next prompt:

Now create the same map as XSLT 2.0 for Saxon. Create a new file.
Enter fullscreen mode Exit fullscreen mode

Same write sequence: launch config first (engine saxonnet), then the stylesheet. No msxsl:script this time — XSLT 2.0 has native functions for everything the C# was doing:

XSLT 1.0 + C# XSLT 2.0 native
fn:PrefixShipmentRef(string(ord:OrderID)) concat('SHP-', ord:OrderID)
fn:ReformatDate(string(ord:OrderDate)) format-date(xs:date(ord:OrderDate), '[D01]/[M01]/[Y0001]')
fn:ExecutionDate() format-dateTime(current-dateTime(), '[Y0001]-[M01]-[D01]T[H01]:[m01]:[s01]Z')

The hook fires on the .xslt write:

Same output structure, same values. Two engines, two versions, same result — both verified by the hook automatically.

ClaudeCodePrompt2


Step 3: LML compiled to XSLT 3.0

Now the same map authored in the Data Mapper's LML format — the visual designer's YAML source that compiles to XSLT 3.0. This is the most complex path because it has dependencies that must be created in the right order.

The prompt

Now create an LML for the same Order-to-Shipment mapping and test it.
Enter fullscreen mode Exit fullscreen mode

The dependency chain

Claude reads the LML file and prepares the next actions

ClaudeReadsLMLMD

LML maps can reference custom extension functions defined in separate XML files. Before Claude can write the LML, it needs to create these functions. The full write sequence for LML mode is:

  1. Launch config — debug configuration pointing at the compiled .xslt (not the .lml)
  2. Custom functionsArtifacts/DataMapper/Extensions/Functions/ShipmentFunctions.xml
  3. LML fileArtifacts/MapDefinitions/Order2Shipment_20260327-lml.lml (written last — the compile hook fires on this, using lml-compile)

The launch config points at Artifacts/Maps/Order2Shipment_20260327-lml.xslt — a file that doesn't exist yet. It will be generated when the LML compile hook fires. Engine is saxonnet because LML always compiles to XSLT 3.0.

The custom functions

Claude creates three functions in ShipmentFunctions.xml:

<customfunctions>
  <function name="prefixShipRef" as="xs:string">
    <param name="orderId" as="xs:string"/>
    <value-of select="concat('SHP-', $orderId)"/>
  </function>

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

  <function name="executionDateTime" as="xs:string">
    <value-of select="format-dateTime(...)"/>
  </function>
</customfunctions>
Enter fullscreen mode Exit fullscreen mode

No hook fires — this is an .xml file, not .lml or .xslt. But this file has a bug that won't surface until the LML compiles. executionDateTime has zero parameters.

The LML file

$version: 1
$input: XML
$output: XML
$sourceSchema: OrderSchema.xsd
$targetSchema: ShipmentOutput.xsd
$sourceNamespaces:
  ns0: http://schemas.contoso.com/Order
Shipments:
  Shipment:
    ShipmentRef: prefixShipRef(/ns0:Order/ns0:OrderID)
    ShipDate: reformatDate(/ns0:Order/ns0:OrderDate)
    Recipient: /ns0:Order/ns0:Customer/ns0:CustomerName
    ExecutionDate: executionDateTime()
    Lines:
      $for(/ns0:Order/ns0:Items/ns0:Item):
        $if(is-greater-or-equal(ns0:Quantity, 1)):
          Line:
            Product: ns0:ProductName
            Quantity: ns0:Quantity
Enter fullscreen mode Exit fullscreen mode

Claude writes this file. The LML compile hook fires — calling lml-compile, a dotnet global tool that wraps the Logic Apps SDK's DataMapTestExecutor.GenerateXslt(). And now the fixing cycle begins.

The fixing cycle

The executionDateTime function has zero parameters — the SDK's ReadCustomFunctionsDefinitionAsync() hits a NullReferenceException on parameters.Length and silently skips the entire XML file. All three functions become "unrecognized". Claude adds a dummy parameter, edits the LML, and the hook fires again — second error: is-greater-or-equal isn't a real LML pseudofunction. Claude switches to xpath("ns0:Quantity >= 1"), edits the LML one more time, and the hook returns clean output.

FixingCycle

Three iterations, two bugs, one prompt. The compile hook drove the entire fix cycle — feeding each error back into Claude's context so it could diagnose and fix without manual intervention.

Iteration Error Root cause Fix
1 executionDateTime unrecognized Zero-param SDK bug — null parameters skips entire XML file Add dummy <param>
2 is-greater-or-equal unrecognized Not a real LML pseudofunction — UI label vs compiler syntax Use xpath("ns0:Quantity >= 1")
3 Success

Three engines, one result

All three implementations now produce matching output from the same input:

Engine Version Date logic String logic Filter
compiled XSLT 1.0 C# DateTime.TryParse C# string.Concat xsl:if test="ord:Quantity >= 1"
saxonnet XSLT 2.0 format-date(xs:date(...)) concat(...) xsl:if test="ord:Quantity >= 1"
saxonnet XSLT 3.0 (LML) ef:reformatDate(...) ef:prefixShipRef(...) xsl:when test="ns0:Quantity >= 1"
<!-- All three produce: -->
<ShipmentRef>SHP-ORD-1001</ShipmentRef>
<ShipDate>31/10/2025</ShipDate>
<Recipient>John Doe</Recipient>
Enter fullscreen mode Exit fullscreen mode

The hook verified each automatically at write time. This matters for teams migrating between engines — you can write the same map in XSLT 1.0 for BizTalk and XSLT 3.0 for Logic Apps, and verify both produce identical output in the same session.


What you actually see in VS Code

When the hook fires during a Claude response, the output appears inline in Claude's context — a PostToolUse system reminder that Claude reads as part of the same turn. You don't see a separate panel or dialog.

For the XSLT 1.0 clean path:

Claude: [reads Order.xml, ShipmentOutput.xsd, launch.json]
Claude: [writes launch.json entry]
Claude: [writes Order2Shipment_20260327.xslt]
   Hook: PostToolUse:Write  Transform result: <Shipments>...</Shipments>
Claude: "Transform ran, output correct."
Enter fullscreen mode Exit fullscreen mode

For the LML fixing cycle:

Claude: [writes launch.json entry]
Claude: [writes ShipmentFunctions.xml]
Claude: [writes Order2Shipment_20260327-lml.lml]
  ← Hook: PostToolUse:Write — ERROR: 'executionDateTime' unrecognized
Claude: [edits ShipmentFunctions.xml — adds dummy param]
Claude: [edits .lml — passes dummy argument]
  ← Hook: PostToolUse:Edit — ERROR: 'is-greater-or-equal' unrecognized
Claude: [edits .lml — switches to xpath()]
  ← Hook: PostToolUse:Edit — LML compiled + Transform result: <Shipments>...</Shipments>
Claude: "Three iterations, all fixed. Output matches."
Enter fullscreen mode Exit fullscreen mode

From your side, you prompted once. Claude read the schemas, created the files, hit three errors, fixed all three, and came back with a verified transform. The iteration happened between Claude and the hook.


The full picture

Three parts, one system:

Skill — gives Claude the rules before it writes a line. Engine classification, version discipline, write order, debug taxonomy, LML syntax, known SDK bugs. Prevents common mistakes and provides the knowledge to diagnose uncommon ones.

Hooks — close the feedback loop after every write and edit. Compile on LML change using lml-compile (a dotnet global tool that wraps the SDK compiler). Run transform on XSLT change via the Debugger HTTP API. Return results — output or error — to Claude's context automatically.

The loop — Claude writes → hook runs → Claude reads result → Claude fixes if needed → hook runs again. One prompt from you; the iteration happens between Claude and the hook.

The skill and the hooks are independent — the skill works without the hooks (slower iteration, manual verification), the hooks work without the skill (no domain rules, more trial and error). Together they make a working environment where the AI can do real XSLT work reliably: catch silent namespace bugs before they reach production, fix C# compile errors in the same turn, iterate through LML compiler quirks without you lifting a finger.

The result isn't "AI-generated XSLT" that you have to review and test manually. It's XSLT that was already tested — by the same system that wrote it, in the same conversation turn, against real input data.


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

Top comments (0)