Compiler vs LLM: A Deep Dive
Why the same spec produces wildly different outputs—and how we fixed it after 14 API failures.
The Setup: What We Were Building
We needed to generate AI Employee configurations for the Ema platform. These "personas" have two critical pieces:
workflow_def— a JSON graph defining how the AI processes requests (triggers, routing, search, response generation)proto_config— a JSON object defining UI/voice/chat settings (widgets, voice characteristics, welcome messages)
Both must conform to an API contract that isn't fully documented. Deviations cause cryptic errors or silent failures.
We tried two approaches:
- Compiler approach: Parse user intent → build typed spec → compile to JSON
- LLM-orchestrated approach: Parse intent → create from template → merge configs → let the LLM handle transformations
This post documents what each produced, why they differed, and which problems appeared at each level.
The API Contract: What the Server Actually Expects
Before comparing outputs, here's what the Ema API requires (learned through failure):
Widget Format
// CORRECT: What the API accepts
{
"name": "conversationSettings",
"type": 39,
"conversationSettings": {
"welcomeMessage": "Hello, how can I help?",
"identityAndPurpose": "You are a sales assistant..."
}
}
// WRONG: What our compiler initially produced
{
"widget_name": "conversationSettings",
"widget_type_id": 39,
"widget_config": {
"welcomeMessage": "Hello, how can I help?"
}
}
The config values must be nested under a key matching the widget's name field — not under a generic widget_config key.
Action Format
// CORRECT: Full action structure
{
"name": "search_1",
"action": {
"name": { "namespaces": ["actions", "emainternal"], "name": "search" },
"version": "v2"
},
"inputs": { ... },
"displaySettings": {
"displayName": "Knowledge Search",
"description": "",
"coordinates": { "x": 800, "y": 200 },
"showConfig": 0
},
"typeArguments": {},
"tools": [],
"disableHumanInteraction": false
}
// WRONG: Missing required fields
{
"name": "search_1",
"action": {
"name": { "namespaces": ["search", "emainternal"], "name": "search" }
},
"inputs": { ... }
}
Notice: namespaces must be ["actions", "emainternal"], not ["search", "emainternal"]. And version, displaySettings, typeArguments, tools are all required.
Approach 1: The Compiler
How It Works
flowchart LR
A[User Input]:::primary --> B[parseNaturalLanguage]:::secondary --> C[WorkflowIntent]:::secondary --> D[intentToSpec]:::secondary --> E[WorkflowSpec]:::secondary --> F[compileWorkflow]:::accent --> G[JSON]:::accent
The compiler is deterministic. Given the same WorkflowSpec, it always produces the same workflow_def and proto_config.
The Code
// workflow-generator.ts (simplified)
function compileWorkflow(spec: WorkflowSpec): CompiledWorkflow {
const actions = spec.nodes.map(node => buildAction(node));
return {
workflow_def: {
workflowName: { name: { namespaces: ["ema", "workflows"], name: spec.name } },
actions,
enumTypes: [],
results: buildResultMappings(spec.resultMappings),
},
proto_config: buildProtoConfig(spec),
};
}
function buildAction(node: Node): Record<string, unknown> {
const namespaces = ACTION_NAMESPACES[node.actionType]; // Static lookup
const version = ACTION_VERSIONS[node.actionType]; // Static lookup
return {
name: node.id,
action: { name: { namespaces, name: node.actionType }, version },
inputs: buildInputs(node.inputs),
displaySettings: { displayName: node.displayName, ... },
typeArguments: {},
tools: [],
};
}
What It Produced (First Version)
For input: "Voice AI for sales development"
Generated proto_config.widgets:
[
{
"widget_name": "conversationSettings",
"widget_type_id": 39,
"widget_config": {}
},
{
"widget_name": "voiceSettings",
"widget_type_id": 38,
"widget_config": {}
}
]
Problems:
- Used
widget_nameinstead ofname - Used
widget_type_idinstead oftype - Used generic
widget_configinstead ofconversationSettings: {...} - Config objects were empty — no welcome message, no identity
API Error:
Invalid persona config: Widget name is empty, persona_id: abc123
Why the Compiler Failed
The compiler had outdated assumptions about the API contract. It was written when the API accepted widget_name + widget_config. The API changed. The compiler didn't.
More fundamentally: compilers validate syntax, not semantics. The TypeScript compiler was happy. The output was "correct" TypeScript. But the API rejected it because the structure didn't match the runtime contract.
After Fixing the Compiler
We corrected the widget format:
function buildProtoConfig(spec: WorkflowSpec): Record<string, unknown> {
const widgets = [];
if (spec.personaType === "voice") {
const vc = spec.voiceConfig ?? {};
widgets.push({
name: "conversationSettings", // Fixed: "name" not "widget_name"
type: 39, // Fixed: "type" not "widget_type_id"
conversationSettings: {
// Fixed: config under matching key
welcomeMessage: vc.welcomeMessage ?? `Hello, this is ${spec.name}.`,
identityAndPurpose: vc.identityAndPurpose ?? spec.description,
takeActionInstructions: vc.takeActionInstructions ?? "...",
// ... full config
},
});
}
return { project_type: 5, widgets };
}
New output:
{
"name": "conversationSettings",
"type": 39,
"conversationSettings": {
"welcomeMessage": "Hello, this is Sales Assistant. How can I help you today?",
"identityAndPurpose": "You are Sales Assistant. Voice AI for sales development."
}
}
Widget config now passes validation.
Approach 1 Continued: The workflow_def Problem
Fixing proto_config wasn't enough. The workflow itself had issues.
What the Compiler Produced
{
"workflowName": {
"name": {
"namespaces": ["ema", "workflows"],
"name": "sales_assistant"
}
},
"actions": [
{
"name": "voice_trigger",
"action": {
"name": {
"namespaces": ["triggers", "emainternal"],
"name": "voice_trigger"
}
},
"inputs": {}
},
{
"name": "search_1",
"action": {
"name": { "namespaces": ["search", "emainternal"], "name": "search" }
},
"inputs": {
"query": {
"actionOutput": {
"actionName": "voice_trigger",
"output": "user_query"
}
}
}
}
]
}
API Error
Internal Server Error
No detail. Just 500.
What Was Wrong
After comparing with a working workflow from the UI:
| Field | Compiler Output | Expected |
|---|---|---|
| search namespace | ["search", "emainternal"] |
["actions", "emainternal"] |
| action.version | (missing) | "v2" |
| displaySettings | (missing) | { displayName: "", coordinates: {...} } |
| typeArguments | (missing) | {} |
| tools | (missing) | [] |
| top-level enumTypes | (missing) | [] |
| top-level namedResults | (missing) | {} |
The compiler's buildAction() was missing 5 required fields. And the namespace mapping was wrong for search.
The Fix
const ACTION_NAMESPACES: Record<ActionType, string[]> = {
search: ["actions", "emainternal"], // Fixed: was ["search", "emainternal"]
respond_with_sources: ["actions", "emainternal"], // Fixed: was ["generation", "emainternal"]
// ...
};
const ACTION_VERSIONS: Record<ActionType, string> = {
search: "v2", // Added: version matters
chat_trigger: "v1",
// ...
};
function buildAction(node: Node): Record<string, unknown> {
return {
name: node.id,
action: {
name: {
namespaces: ACTION_NAMESPACES[node.actionType],
name: node.actionType,
},
version: ACTION_VERSIONS[node.actionType] ?? "v1", // Added
},
inputs: buildInputs(node.inputs),
displaySettings: {
// Added entire block
displayName: node.displayName ?? "",
description: node.description ?? "",
coordinates: { x: 800, y: 200 },
showConfig: 0,
},
typeArguments: {}, // Added
tools: [], // Added
disableHumanInteraction: node.disableHitl ?? false,
};
}
Still Failed
Even with correct format, another error:
Workflow name does not match the persona's existing workflow
The compiler generated workflowName as sales_assistant. But when creating a new persona from template, the platform assigns its own workflow name. We were trying to overwrite it with a mismatched name.
This is where the compiler approach fundamentally broke down.
Approach 2: LLM-Orchestrated with Template Preservation
The Insight
The platform's templates already have valid workflow_def structures. Instead of generating a replacement, we should:
- Create persona from template (get valid workflow)
- Only update
proto_config(settings, not structure) - Customize workflow later via transformation, not replacement
The New Architecture
flowchart TB
subgraph GREENFIELD["GREENFIELD (New Persona)"]
G1[User Intent]:::primary --> G2[Create from Template]:::secondary
G2 --> G3[Fetch Created Persona]:::secondary
G3 --> G4["Merge proto_config"]:::secondary
G4 --> G5["Update proto_config ONLY"]:::accent
end
flowchart TB
subgraph BROWNFIELD["BROWNFIELD (Existing Persona)"]
B1[User Intent]:::primary --> B2[Fetch Persona]:::secondary
B2 --> B3[Decompile workflow_def]:::secondary
B3 --> B4["WorkflowSpec (typed)"]:::secondary
B4 --> B5["LLM transforms spec"]:::agent
B5 --> B6[Compile back to workflow_def]:::secondary
B6 --> B7["Update persona"]:::accent
end
The Code
// handlers-consolidated.ts - Greenfield flow
// Step 1: Create persona from template
const createResult = await client.createAiEmployee({
name: personaName,
description: args.description,
template_id: templateId, // "00000000-0000-0000-0000-00000000001e" for Voice AI
});
const newPersonaId = createResult.persona_id;
// Step 2: Fetch to get template's valid structure
const newPersona = await client.getPersonaById(newPersonaId);
const existingProtoConfig = newPersona.proto_config ?? {};
// Step 3: Merge proto_config - template structure + generated values
const existingWidgets = existingProtoConfig.widgets ?? [];
const generatedWidgets = compiled.proto_config.widgets ?? [];
const widgetMap = new Map<string, Record<string, unknown>>();
// Start with template widgets (valid structure guaranteed)
for (const w of existingWidgets) {
if (typeof w.name === "string" && w.name.trim().length > 0) {
widgetMap.set(w.name, w);
}
}
// Merge generated VALUES into template structure
for (const genWidget of generatedWidgets) {
const widgetName = genWidget.name;
const existing = widgetMap.get(widgetName);
if (existing) {
// Deep merge: keep structure, update values
const merged = { ...existing };
if (genWidget[widgetName]) {
merged[widgetName] = {
...(existing[widgetName] || {}),
...genWidget[widgetName],
};
}
widgetMap.set(widgetName, merged);
} else {
widgetMap.set(widgetName, genWidget);
}
}
const mergedProtoConfig = {
...existingProtoConfig,
widgets: Array.from(widgetMap.values()),
};
// Step 4: Update proto_config ONLY - don't touch workflow
await client.updateAiEmployee({
persona_id: newPersonaId,
proto_config: mergedProtoConfig,
// NOTE: No workflow parameter - template workflow preserved
});
What This Produces
For the same input: "Voice AI for sales development"
Template's proto_config.widgets (after create):
[
{
"name": "conversationSettings",
"type": 39,
"conversationSettings": {
"welcomeMessage": "Hello, I'm your AI assistant.",
"identityAndPurpose": "You are an AI assistant.",
"takeActionInstructions": "Help the user with their request.",
"hangupInstructions": "End the call when the user's request is complete."
}
},
{
"name": "voiceSettings",
"type": 38,
"voiceSettings": {
"languageHints": ["en-US"],
"voiceModel": "default"
}
}
]
Generated values to merge:
{
"conversationSettings": {
"welcomeMessage": "Hello, this is Sales Assistant. How can I help you today?",
"identityAndPurpose": "You are Sales Assistant, a Voice AI for sales development..."
}
}
Final merged output:
[
{
"name": "conversationSettings",
"type": 39,
"conversationSettings": {
"welcomeMessage": "Hello, this is Sales Assistant. How can I help you today?",
"identityAndPurpose": "You are Sales Assistant, a Voice AI for sales development...",
"takeActionInstructions": "Help the user with their request.",
"hangupInstructions": "End the call when the user's request is complete."
}
},
{
"name": "voiceSettings",
"type": 38,
"voiceSettings": {
"languageHints": ["en-US"],
"voiceModel": "default"
}
}
]
Key difference: Template provided valid structure. We only updated specific values. All required fields preserved.
Comparison: What Each Approach Got Wrong
| Issue | Compiler Approach | Template + Merge Approach |
|---|---|---|
| Widget field names | Wrong (widget_name vs name) |
Inherited from template (correct) |
| Config nesting | Wrong (widget_config vs [widgetName]) |
Inherited from template (correct) |
| Empty configs | Yes — no defaults | No — template has defaults |
| Missing action fields | Yes — 5 missing fields | N/A — don't replace workflow |
| Wrong namespaces | Yes — ["search", ...] |
N/A — don't replace workflow |
| Workflow name mismatch | Yes — generates new name | N/A — keep template workflow |
| First deploy success rate | 0% (14 failures) | 100% (after architecture change) |
The Brownfield Case: LLM-Native Transformation
For existing personas, we need to modify workflows. This is where LLMs shine.
The Schema
// workflow-transformer.ts
export const WORKFLOW_SCHEMA_FOR_LLM = `
# Ema Workflow Schema
## WorkflowSpec
interface WorkflowSpec {
name: string;
description: string;
personaType: "voice" | "chat" | "dashboard";
nodes: Node[];
resultMappings: ResultMapping[];
}
## Node
interface Node {
id: string; // Unique identifier
actionType: ActionType; // search, call_llm, categorizer, etc.
displayName: string;
inputs?: Record<string, InputBinding>;
runIf?: RunIfCondition; // Conditional execution
}
## InputBinding (How nodes connect)
interface InputBinding {
type: "action_output" | "inline_string" | "widget_config" | "llm_inferred";
actionName?: string; // Source node
output?: string; // Source output
value?: string; // Inline value
}
`;
How It Works
// Decompile: workflow_def (JSON) → WorkflowSpec (typed)
function decompileWorkflow(workflowDef: Record<string, unknown>): WorkflowSpec {
const actions = workflowDef.actions as Array<Record<string, unknown>>;
return {
name: extractWorkflowName(workflowDef),
description: (workflowDef.description as string) || "",
personaType: detectPersonaType(actions),
nodes: actions.map((action) => decompileAction(action)),
resultMappings: extractResultMappings(workflowDef.results),
};
}
// LLM receives: current spec + user instruction + schema
// LLM outputs: transformed spec
// Compile: WorkflowSpec → workflow_def
// Uses the same compileWorkflow() from before, but now with correct format
Why This Works Better
-
LLMs understand intent: "Add a search node before the response" → LLM knows to wire
trigger.user_query → search.query → respond.search_results -
Schema constrains output: The LLM can only produce valid
WorkflowSpecshapes -
Compile handles format: The fixed compiler turns the spec into API-compliant JSON
-
Existing structure preserved: We decompile first, so all existing nodes/wiring remain
Results: The Numbers
| Metric | Pure Compiler | Template + Merge |
|---|---|---|
| Greenfield deploy success | 0% | 100% |
| API validation errors | 14 distinct types | 0 |
| Fields requiring manual fix | 12 | 0 |
| Time to first working deploy | 3+ hours debugging | Instant |
| Widget config completeness | Empty | Full (template + custom) |
Lessons Learned
1. Compilers enforce their contract, not the target's
The TypeScript compiler validated our code structure. It couldn't validate that ["search", "emainternal"] should be ["actions", "emainternal"]. That's a runtime contract the compiler knows nothing about.
2. Templates are contracts made concrete
Instead of reverse-engineering the API contract by trial and error, we used templates as a source of truth. If the template works, and we only add/modify values without changing structure, our output will work.
3. Merge, don't replace
The instinct was: "Generate complete output, deploy it."
The better approach: "Get working structure, merge our changes into it."
4. LLMs excel at transformation, not generation from scratch
Generating valid workflow_def from nothing required knowing every undocumented field. Transforming an existing valid workflow_def? The LLM just needs to understand the changes.
5. Validate at the boundary
We added widget validation before API calls:
function validateWidgetsForApi(widgets: Array<Record<string, unknown>>): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
for (const w of widgets) {
if (typeof w.name !== "string" || w.name.trim().length === 0) {
errors.push(`Widget missing valid name: ${JSON.stringify(w)}`);
}
if (typeof w.type !== "number") {
errors.push(`Widget missing type ID: ${w.name}`);
}
}
return { valid: errors.length === 0, errors };
}
Catch problems before the API call. Much better than "Internal Server Error".
The Architecture That Worked
flowchart LR
subgraph GREENFIELD["GREENFIELD"]
G1[Intent]:::primary --> G2[Template]:::secondary --> G3[Fetch]:::secondary --> G4[Merge Config]:::secondary --> G5[Update Config]:::accent
end
Template provides valid workflow — don't touch it
flowchart LR
subgraph BROWNFIELD["BROWNFIELD"]
B1[Intent]:::primary --> B2[Fetch]:::secondary --> B3[Decompile]:::secondary --> B4[LLM Transform]:::agent --> B5[Compile]:::secondary --> B6[Update]:::accent
end
Existing workflow is source of truth — transform it
The compiler still exists. It handles WorkflowSpec → workflow_def conversion. But it no longer runs in isolation. It's part of a pipeline where valid structure comes from templates/existing data, and the compiler's job is format conversion — not structure invention.
Try This
If you're integrating with an API that has undocumented structural requirements:
- Get a working example — from the UI, from templates, from anywhere
- Diff your output against the working example — field by field
- Prefer modification over generation — transform existing valid structures
- Validate at boundaries — catch format errors before the API call
- Let LLMs handle intent — they're good at "add X to Y" transformations
- Let compilers handle correctness — after the structure is valid
The combination of template-based scaffolding + LLM transformation + compiler validation caught everything that each approach alone would have missed.
This post is part of a series on AI-assisted development workflows. See From Compiler Workflows to LLM Workflows for the conceptual foundation, and The MCP Mental Model for knowledge-first development patterns.