Part 1 covered the XSLT skill — domain knowledge that prevents mistakes upfront. This part covers the runtime side: two PostToolUse hooks that automate compile-and-run so Claude sees the result of its own edits immediately.
Claude writes a file → hooks fire → output (or error) appears in context → Claude fixes and repeats. When the debugger is running and a matching launch config exists, no manual runs are needed. Otherwise the hook reports what's missing and Claude can guide you.
PostToolUse hooks
Claude Code hooks are shell commands that fire after tool events. PostToolUse fires after every Write or Edit — meaning iterative fixes get immediate feedback on every change.
Configure in .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/generate-xslt-from-lml.sh",
"timeout": 45,
"statusMessage": "Compiling LML + running transform..."
},
{
"type": "command",
"command": "bash .claude/hooks/run-xslt-after-edit.sh",
"timeout": 30,
"statusMessage": "Running XSLT transform..."
}
]
}
]
}
}
Both hooks filter on file extension internally — the first acts only on .lml, the second only on .xslt.
Hook 1: LML compile
When Claude edits a .lml file, the designer isn't involved — the compiled .xslt would be stale. This hook compiles it and runs the transform in one step.
The compiler: lml-compile
The Logic Apps testing SDK includes DataMapTestExecutor with a GenerateXslt() method. I wrapped it as a .NET global tool:
dotnet tool install -g lml-compile
No running Logic Apps host. No Azurite. Just:
lml-compile input.lml output.xslt
The hook
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only act on .lml files
case "$FILE_PATH" in *.lml) ;; *) exit 0 ;; esac
# Derive output path
BASENAME=$(basename "$FILE_PATH" .lml)
MAPS_DIR="$(cd "$(dirname "$FILE_PATH")/.." && pwd)/Maps"
OUT_XSLT="${MAPS_DIR}/${BASENAME}.xslt"
mkdir -p "$MAPS_DIR"
# Compile
set +e
COMPILE_OUTPUT=$(lml-compile "$FILE_PATH" "$OUT_XSLT" 2>&1)
COMPILE_EXIT=$?
set -e
if [ $COMPILE_EXIT -ne 0 ]; then
jq -n --arg err "$COMPILE_OUTPUT" '{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": ("LML compile failed: " + $err)
}
}'
exit 0
fi
# Find workspace and run transform via debugger HTTP API
# (walks up from file to find .vscode/launch.json)
# ... matches launch config, calls POST /run-transform ...
jq -n --arg ctx "$OUTPUT" '{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": ("LML compiled + transform result:\n" + $ctx)
}
}'
If compile fails, Claude sees the error. If it succeeds and the debugger is running with a matching launch config, Claude sees the transform output in the same turn. If the debugger isn't running or no config matches, the hook reports the compile success and what's missing.
Hook 2: Transform runner
Fires when Claude writes or edits a .xslt file directly (Generate mode).
The key challenge: workspace derivation. In hook context, $(pwd) doesn't point to the project — it can resolve to /private/tmp. The fix: walk up from the edited file's path to find .vscode/launch.json:
DIR="$(cd "$(dirname "$EDITED_FILE")" && pwd)"
WORKSPACE=""
while [[ "$DIR" != "/" ]]; do
if [[ -f "$DIR/.vscode/launch.json" ]]; then
WORKSPACE="$DIR"; break
fi
DIR="$(dirname "$DIR")"
done
The hook then reads the matching launch config (resolving ${workspaceFolder}), extracts the input XML and engine, and calls the XSLT Debugger's HTTP API:
RESULT=$(curl -s -X POST "http://127.0.0.1:$PORT/run-transform" \
-H "Content-Type: application/json" \
-d "{\"stylesheet\": \"$STYLESHEET\", \"xml\": \"$XML\", \
\"engine\": \"$ENGINE\"}")
The result is returned via hookSpecificOutput so Claude sees the transform output in context.
How the hooks interact
Claude writes .xslt (Generate mode):
Claude → Write .xslt → Hook 1 ignores (not .lml) → Hook 2 runs transform → result to Claude
Claude writes .lml (LML mode):
Claude → Write .lml → Hook 1 compiles + runs transform → result to Claude → Hook 2 ignores (not .xslt)
The .xslt file written by lml-compile does not trigger Hook 2 — only files Claude writes directly via Write or Edit trigger hooks.
The feedback loop
Claude writes XSLT → hook runs transform → output in context → error? → Claude edits → hook runs again → correct output
No human in the loop for the fix cycle. Claude writes, sees the result, fixes if needed, sees the result again.
Shell bugs worth knowing
set -e with || true: Using OUTPUT=$(cmd) || true resets $? to 0 — the failure branch is unreachable. Fix: set +e, capture $?, then set -e.
grep -v returns exit 1 when no lines match: Under set -euo pipefail, this kills the hook. Append || true to the grep specifically.
$(pwd) in hook context: Doesn't inherit the project directory. Always derive workspace from the edited file's path.
The pattern generalises
The pattern is: skill (domain rules) + hook (verify after every write) = self-correcting AI. Nothing here is XSLT-specific except the domain knowledge and the tools the hooks call.
The same approach works for Terraform (terraform validate + terraform plan after .tf edits), SQL migrations, OpenAPI specs — anything where a write can be verified automatically.
The XSLT Debugger extension is on the VS Code Marketplace for macOS and Windows.
Top comments (0)