Reverse-engineering Five9's XML — and what we kept verbatim
When we set out to build ivrloom, the second-hardest thing to get right (after the offline-first architecture) was the Universal IR — the canonical, vendor-neutral data shape every part of the product reads and writes. The hardest part wasn’t TypeScript types. It was reverse-engineering Five9’s XML thoroughly enough to round-trip a large production IVR with high fidelity — preserving even the elements we don’t yet fully model.
What we knew before opening a real .five9ivr
Almost nothing. Five9’s public documentation describes the IVR Script Designer’s features but never publishes the XML schema. Our initial node-type list was guesses based on docs page titles — MENU, PLAY_PROMPT, SKILL_TRANSFER, RUN_SUB_SCRIPT. We even codified this list in our spec docs.
It was all wrong.
What real .five9ivr files look like
The actual element names are lowercase camelCase matching Java conventions:
<modules>
<incomingCall>...</incomingCall>
<getDigits>...</getDigits>
<case>...</case>
<foreignScript>...</foreignScript>
<ifElse>...</ifElse>
<hangup>...</hangup>
</modules>
There’s no <MENU> element. What we thought was a menu node is just a <getDigits> node with multiple <filePrompt> children played in sequence before input collection. The “menu” is the prompt list inside the input node.
Same for sub-scripts: the element is <foreignScript>, not RUN_SUB_SCRIPT. Same for conditions: <ifElse> and <case>, not IF or SWITCH.
The four findings that changed our design
1. The <functions> block contains compressed JavaScript
This was the surprise. Some Five9 IVRs include inline JavaScript at script scope:
<functions>
<entry>
<value>
<name>NormalizePhone</name>
<returnType>STRING</returnType>
<functionBody>H4sIAAAAAAAAAE2MuwqDMBSG9zzFT6YIJbSuwYIv0...</functionBody>
</value>
</entry>
</functions>
That functionBody decodes from base64 to gzip-compressed JavaScript source. Five9 runs it at IVR runtime. Any importer that decodes but doesn’t re-compress correctly will silently break the IVR.
Our IR stores the decoded plaintext JS for human readability AND the original encoded blob byte-identical. On export, if the source is unchanged, we re-emit the original blob exactly. If the user edited the JS, we recompress deterministically (gzip level 6, mtime=0) to match Python’s gzip.compress(..., mtime=0).
2. <events> are per-module exception handlers, not separate nodes
Each input-collecting node carries inline event handlers:
<getDigits>
...
<events>
<event>NO_MATCH</event>
<count>1</count>
<action>CONTINUE</action>
</events>
</getDigits>
We initially thought these were edges. They’re not — they’re behavior attached to the parent node. Our IR models them as a property on the node, not as separate graph nodes.
3. <case> is a multi-arm guarded conditional, not a switch
Each <entry> in a <case> has its own complete <conditions> block:
<case>
<data>
<branches>
<entry>
<key>billing</key>
<value>
<conditions>
<comparisonType>EQUALS</comparisonType>
<leftOperand><variableName>__BUFFER__</variableName></leftOperand>
<rightOperand><stringValue><value>1</value></stringValue></rightOperand>
</conditions>
</value>
</entry>
</branches>
</data>
</case>
This means each case arm can have its own multi-clause condition with MORE_THAN, CONTAINS, etc. — not just a simple value match. Our IR mirrors this.
4. The queue-callback feature explodes into 10+ sibling elements
A single “offer queue callback” operation exports as <callbackAllowInternational>, <callbackPhoneNumberPrompt>, <callbackConfirmationPrompt>, <callbackConfirmingPhoneNumberPrompt>, <callbackEnteringPhoneNumberPrompt>, <callbackRecordingCallerNamePrompt>, <callbackDigit>, <callbackEnterDigitsMaxTimeSec>, <callbackQueueTimeoutSec>, <callbackNumberFromCav> — all siblings, all required, all part of one logical feature.
Our IR models them as a single callbackOffer node so the flow stays navigable on the canvas. Full structured editing of the callback fields — and lossless re-serialization back to the canonical XML — is on the v1.5 roadmap; v1 preserves the node and its edges but re-emits an empty data block on export, so we don’t yet recommend round-tripping a populated callback flow.
The lossless-fallback rule
For every Five9 element we don’t model — and there are several we haven’t gotten to yet, like <crmUpdate>’s field structure — we store the verbatim XML in vendorExtensions._rawXml on the IR node, and re-emit it byte-identical on export. Edges still parse so the graph is navigable, but the node is rendered as an opaque “vendor-specific” card in the editor.
This is what keeps the fallback honest for elements we don’t recognize: any unknown element is preserved byte-for-byte via its raw XML, so it comes out exactly as it went in. The gap today is the handful of elements we do model but haven’t finished serializing (queue callback and the CRM steps re-emit an empty data block) — closing those is the top of the v1.5 list.
What we learned
If you’re ever building a tool that reads someone else’s binary or XML format: don’t design the IR until you have at least a dozen real files in hand. Our six-node guess from documentation got two-thirds of the names wrong. The actual schema was both simpler (fewer node types than expected) and richer (the <functions> JS, the inline events, the case-arm conditions).
The IR is the contract between every part of the product. Get it wrong, and you’ll either keep round-trip-corrupting customer data, or you’ll be rewriting the editor every time a real export reveals another quirk. Get it right, and everything else — the visual editor, the AI proposals, the diff view, the simulator — composes naturally on top.
If you run a Five9 IVR, this is what you get to work with: ivrloom is a browser-based Five9 IVR Script Designer alternative built on exactly this IR — see plans and pricing.