{
  "id": "",
  "name": "Reliable Structured Output from AI Agent Without the Structured Output Parser - with OpenAI & Switch node",
  "tags": [],
  "nodes": [
    {
      "id": "",
      "name": "AI Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -400,
        760
      ],
      "parameters": {
        "text": "={{ $json.chatInput }}",
        "options": {
          "maxIterations": 10,
          "systemMessage": "=You are a helpful assistant specialized in nutrition.  \nYour task is to provide accurate nutritional information for a given food item.  \nYou must return your answer strictly in the form of a JSON object matching the following schema:\n\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"alimentName\": {\n      \"type\": \"string\",\n      \"description\": \"The name of the food item, in English\"\n    },\n    \"averageCalories\": {\n      \"type\": \"number\",\n      \"description\": \"Average calories per 100g or standard portion (kcal)\"\n    },\n    \"proteins\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of protein per 100g or portion (grams)\"\n    },\n    \"carbohydrates\": {\n      \"type\": \"number\",\n      \"description\": \"Total amount of carbohydrates per 100g or portion (grams), including fiber and sugars\"\n    },\n    \"sugar\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of total sugars (subset of carbohydrates) per 100g or portion (grams)\"\n    },\n    \"fiber\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of dietary fiber (subset of carbohydrates) per 100g or portion (grams)\"\n    },\n    \"fat\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of fat per 100g or portion (grams)\"\n    },\n    \"sodium\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of sodium per 100g or portion (milligrams)\"\n    },\n    \"healthyScore\": {\n      \"type\": \"number\",\n      \"minimum\": 0,\n      \"maximum\": 10,\n      \"description\": \"A healthiness score from 0 (very unhealthy) to 10 (very healthy), based on nutritional guidelines\"\n    }\n  },\n  \"required\": [\n    \"alimentName\",\n    \"averageCalories\",\n    \"proteins\",\n    \"carbohydrates\",\n    \"sugar\",\n    \"fiber\",\n    \"fat\",\n    \"sodium\",\n    \"healthyScore\"\n  ]\n}\n\n\nIf the user input is not a valid food item, or if you are unsure whether it is a real food, then instead return:\n\n{\n  \"error\": \"invalid_input\",\n  \"message\": \"The provided input does not appear to be a valid food item.\"\n}\n\n----\n\n\u26a0\ufe0f If you fail to produce output in the correct schema, the Schema Error Prompt below will contain an error message. You will need to follow the instructions it provides:\n\n## Schema Error Prompt:\n\n{{ $json.schemaErrorPrompt }}\n\n",
          "returnIntermediateSteps": true
        },
        "promptType": "define"
      },
      "executeOnce": false,
      "typeVersion": 1.8
    },
    {
      "id": "",
      "name": "When chat message received",
      "type": "@n8n/n8n-nodes-langchain.chatTrigger",
      "position": [
        -800,
        760
      ],
      "parameters": {
        "mode": "webhook",
        "public": true,
        "options": {
          "responseMode": "responseNode"
        },
        "authentication": "basicAuth"
      },
      "credentials": {
        "httpBasicAuth": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        -400,
        920
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "=gpt-4.1-nano"
        },
        "options": {
          "temperature": 0.8,
          "responseFormat": "json_object"
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "",
      "name": "Simple Memory",
      "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
      "position": [
        -260,
        920
      ],
      "parameters": {},
      "typeVersion": 1.3
    },
    {
      "id": "",
      "name": "Switch",
      "type": "n8n-nodes-base.switch",
      "position": [
        860,
        760
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "invalidSchema",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.output.error !== undefined && $json.aiRunIndex < 3 }}",
                    "rightValue": ""
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "validSchema",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "",
                    "operator": {
                      "type": "string",
                      "operation": "exists",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.output.alimentName }}",
                    "rightValue": ""
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3.2
    },
    {
      "id": "",
      "name": "Validate Output + Set `aiRunIndex`",
      "type": "n8n-nodes-base.set",
      "onError": "continueRegularOutput",
      "position": [
        260,
        760
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "",
              "name": "output",
              "type": "object",
              "value": "={{ \n(() => {\n\tlet raw = $json.output;\n\n\tif (typeof raw === 'string') {\n\t\traw = raw\n\t\t\t.replace(/^\\s*```json/i, '')\n\t\t\t.replace(/```$/i, '')\n\t\t\t.trim();\n\t\ttry { raw = JSON.parse(raw); }\n\t\tcatch { return { error: 'invalid_json' }; }\n\t}\n\n\t// Allow alternative valid response when input is not a valid food item\n\tif (\n\t\traw.error === 'invalid_input' &&\n\t\traw.message === 'The provided input does not appear to be a valid food item.'\n\t) {\n\t\treturn JSON.stringify(raw, null, 2);\n\t}\n\n\t// Check required keys\n\tconst requiredKeys = [\n\t\t'alimentName',\n\t\t'averageCalories',\n\t\t'proteins',\n\t\t'carbohydrates',\n\t\t'sugar',\n\t\t'fiber',\n\t\t'fat',\n\t\t'sodium',\n\t\t'healthyScore'\n\t];\n\n\tfor (const key of requiredKeys) {\n\t\tif (!(key in raw)) {\n\t\t\treturn { error: 'missing_key', key };\n\t\t}\n\t}\n\n\t// Type checks\n\tif (typeof raw.alimentName !== 'string')\n\t\treturn { error: 'invalid_type', key: 'alimentName', expected: 'string' };\n\n\tif (typeof raw.averageCalories !== 'number')\n\t\treturn { error: 'invalid_type', key: 'averageCalories', expected: 'number' };\n\n\tif (typeof raw.proteins !== 'number')\n\t\treturn { error: 'invalid_type', key: 'proteins', expected: 'number' };\n\n\tif (typeof raw.carbohydrates !== 'number')\n\t\treturn { error: 'invalid_type', key: 'carbohydrates', expected: 'number' };\n\n\tif (typeof raw.sugar !== 'number')\n\t\treturn { error: 'invalid_type', key: 'sugar', expected: 'number' };\n\n\tif (typeof raw.fiber !== 'number')\n\t\treturn { error: 'invalid_type', key: 'fiber', expected: 'number' };\n\n\tif (typeof raw.fat !== 'number')\n\t\treturn { error: 'invalid_type', key: 'fat', expected: 'number' };\n\n\tif (typeof raw.sodium !== 'number')\n\t\treturn { error: 'invalid_type', key: 'sodium', expected: 'number' };\n\n\tif (typeof raw.healthyScore !== 'number')\n\t\treturn { error: 'invalid_type', key: 'healthyScore', expected: 'number' };\n\n\tif (raw.healthyScore < 0 || raw.healthyScore > 10)\n\t\treturn { error: 'invalid_range', key: 'healthyScore', expected: 'number between 0 and 10' };\n\n\t// If all checks pass, return the parsed and formatted JSON\n\treturn JSON.stringify(raw, null, 2);\n})() \n}}"
            },
            {
              "id": "",
              "name": "=aiRunIndex",
              "type": "number",
              "value": "={{ $node[\"AI Agent\"].runIndex }} "
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "",
      "name": "Format Schema Error Prompt",
      "type": "n8n-nodes-base.set",
      "position": [
        920,
        1020
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "",
              "name": "schemaErrorPrompt",
              "type": "string",
              "value": "=If you're seeing this message, it means your previous response did not follow the required output schema defined in your prompt:\n\n{\n  \"type\": \"object\",\n  \"properties\": {\n    \"alimentName\": {\n      \"type\": \"string\",\n      \"description\": \"The name of the food item, in English\"\n    },\n    \"averageCalories\": {\n      \"type\": \"number\",\n      \"description\": \"Average calories per 100g or standard portion (kcal)\"\n    },\n    \"proteins\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of protein per 100g or portion (grams)\"\n    },\n    \"carbohydrates\": {\n      \"type\": \"number\",\n      \"description\": \"Total amount of carbohydrates per 100g or portion (grams), including fiber and sugars\"\n    },\n    \"sugar\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of total sugars (subset of carbohydrates) per 100g or portion (grams)\"\n    },\n    \"fiber\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of dietary fiber (subset of carbohydrates) per 100g or portion (grams)\"\n    },\n    \"fat\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of fat per 100g or portion (grams)\"\n    },\n    \"sodium\": {\n      \"type\": \"number\",\n      \"description\": \"Amount of sodium per 100g or portion (milligrams)\"\n    },\n    \"healthyScore\": {\n      \"type\": \"number\",\n      \"minimum\": 0,\n      \"maximum\": 10,\n      \"description\": \"A healthiness score from 0 (very unhealthy) to 10 (very healthy), based on nutritional guidelines\"\n    }\n  },\n  \"required\": [\n    \"alimentName\",\n    \"averageCalories\",\n    \"proteins\",\n    \"carbohydrates\",\n    \"sugar\",\n    \"fiber\",\n    \"fat\",\n    \"sodium\",\n    \"healthyScore\"\n  ]\n}\n\n\nPlease revise your output to strictly match this structure.\n\nFor reference, the last user message was:\n{{ $('When chat message received').item.json.chatInput }}\n\nAnd your response was:\n{{ $('AI Agent').item.json.output }}\n\nThis does not conform to the expected schema. Please correct your output accordingly."
            },
            {
              "id": "",
              "name": "sessionId",
              "type": "string",
              "value": "={{ $('When chat message received').item.json.sessionId }}"
            },
            {
              "id": "",
              "name": "chatInput",
              "type": "string",
              "value": "={{ $('When chat message received').item.json.chatInput }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "",
      "name": "Valid Schema Output",
      "type": "n8n-nodes-base.set",
      "position": [
        1460,
        760
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "",
              "name": "nutritionalValues",
              "type": "object",
              "value": "={{ $json.output }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        600,
        -260
      ],
      "parameters": {
        "color": 7,
        "width": 520,
        "height": 1140,
        "content": "## Switch\n\n## \u26a0\ufe0f Warning : \ud83d\udd01 **Infinite loop risk**\n\nIf you **modify this node without understanding** the expressions inside, it can cause an infinite loop, which is **exactly what you don\u2019t want** for your n8n instance, your results\u2026 or your API credits ($). \ud83d\ude09\n\nThe logic uses **$json.output.error !== undefined && $json.aiRunIndex < 3** to control retries.  \nChanging this without adjusting the flow logic may cause endless retries or silent failures.\n\n\n----\n\n## How it Works\n\nThis `Switch` node routes the data flow based on validation outcomes:\n\n### \ud83d\udfe5 `invalidSchema`\n- **Condition:**  \n  **{{ $json.output.error !== undefined && $json.aiRunIndex < 3 }}**\n- **Purpose:**  \n  Detects if there's an error in the output **and** the number of AI attempts (`aiRunIndex`) is less than 3.  \n  Used to trigger a retry or a corrective step before giving up.\nIf it gives up, the **Fallback route** with an error message will be used.\n\n### \ud83d\udfe9 `validSchema`\n- **Condition:**  \n  `{{ $json.output.alimentName }} exists`\n- **Purpose:**  \n  Validates that the `alimentName` field is present in the output, implying a successful extraction or transformation.\n\n### \ud83d\udfe8 Fallback (named `extra`)\n- **Purpose:**  \n  Any case not matching the above two will be routed here.  \n  Useful for handling unexpected or unvalidated scenarios.\n\n---\n\n**Good practice:**  \nBefore modifying this node, trace the flow downstream and ensure your logic won't bypass validation or introduce unintended retry loops.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -480,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 1040,
        "content": "## AI Agent\n\n\nThis node sets an **AI agent** to generate nutritional information based on a food name provided via `chatInput`.\n\n\n---\n\n## How it works:\n\n- \ud83d\udcac **Prompt definition:**\n  The agent is instructed to behave like a nutrition expert. It must return a response **strictly in JSON format**, matching a predefined schema with fields like:\n  - `alimentName`\n  - `averageCalories`\n  - `proteins`, `carbohydrates`, `sugar`, `fiber`, `fat`, `sodium`\n  - `healthyScore` (from 0 to 10)\n\n\nHowever, **structured output parser is not used here**, because the node tends to throw errors frequently when parsing is enforced. Instead, the output is manually validated in later steps.\n\n**Check the System Prompt** : it\u2019s designed to **allow the AI Agent** to **receive an \"Error Prompt\"** if the response doesn\u2019t follow the expected schema.\n\n\n\n```\n\u26a0\ufe0f If you fail to produce output in the correct schema, the Schema Error Prompt below will contain an error message. You will need to follow the instructions it provides:\n## Schema Error Prompt:\n{{ $json.schemaErrorPrompt }}\n```\n-  **Error handling:**\n  If the input is not a valid food, the agent must return:\n  ```json\n  {\n    \"error\": \"invalid_input\",\n    \"message\": \"The provided input does not appear to be a valid food item.\"\n  }\n"
      },
      "typeVersion": 1
    },
    {
      "id": "",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        60,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 500,
        "height": 1040,
        "content": "## Validate Output + Set `aiRunIndex`\n\nThis node **validates** the AI output to ensure it matches the expected nutrition schema,  \nand sets a helper variable `aiRunIndex` for retry tracking.\n\nOf course, you'll need to **edit the IIFE that checks the schema** to match the structure you expect.  \nIf you're not comfortable with coding, **OpenAI's o3 model does a great job helping with that**.\n\n\n\n----\n\n## What it does:\n\n- \u2705 **Parses AI response** (if it's a string with ` ```json ` wrapper).\n- \u2705 **Validates structure**:\n  - Checks presence of all required keys (e.g. `alimentName`, `averageCalories`, etc.).\n  - Checks types (`string` for names, `number` for nutrition values).\n  - Validates that `healthyScore` is between `0` and `10`.\n\n- \u274c **If anything fails**, it returns a structured error:\n  - `invalid_json`, `missing_key`, `invalid_type`, or `invalid_range`.\n\n- \ud83d\udd01 **Sets `aiRunIndex`** based on how many times the AI node has run (used later to limit retries).\n\n---\n\n**Note:**  \nThis replaces a structured parser and gives full control over error handling without crashing the workflow."
      },
      "typeVersion": 1
    },
    {
      "id": "",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1160,
        360
      ],
      "parameters": {
        "color": 7,
        "width": 700,
        "height": 520,
        "content": "## Valid Schema Output\n\n\nThis node stores the **validated nutritional data** under a clean variable name: `nutritionalValues`.\n\n\n---\n\n## What it does:\n\n- Takes the parsed and validated `output` from earlier steps.\n- Assigns it to a new variable called `nutritionalValues` for easier access and downstream processing.\n\n---\n\n**Note:**  \nAt this point, the data is assumed to be valid \u2014 all schema and type checks have already been performed.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1160,
        940
      ],
      "parameters": {
        "color": 7,
        "width": 700,
        "height": 600,
        "content": "## Output Handling (Valid & Invalid Schema)\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n## Set schemaValidationError & lastAgentOutput\n- Stores the output of the node \"Validate Output + Set `aiRunIndex`\" in a variable called `schemaValidationError`.\n- Stores the output of the node \"AI Agent\" in a variable called `lastAgentOutput`.\n\n## Set chat Output\n- Constructs a user-facing message in the `output` field using the two previously stored variables.\n- The message explains that a schema validation error occurred and provides the last response from the AI agent as fallback context.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "",
      "name": "Set schemaValidationError & lastAgentOutput",
      "type": "n8n-nodes-base.set",
      "position": [
        1280,
        1020
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "",
              "name": "schemaValidationError",
              "type": "string",
              "value": "={{ $('Validate Output + Set `aiRunIndex`').item.json.output }}"
            },
            {
              "id": "",
              "name": "lastAgentOutput",
              "type": "string",
              "value": "={{ $('AI Agent').item.json.output }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "",
      "name": "Set chat Output",
      "type": "n8n-nodes-base.set",
      "position": [
        1640,
        1020
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "",
              "name": "output",
              "type": "string",
              "value": "=This output was sent because a schema validation error occurred:\n\n{{ $json.schemaValidationError }}\n\nHowever, here is the last AI agent response:\n\n{{ $json.lastAgentOutput }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1580,
        -60
      ],
      "parameters": {
        "color": 5,
        "width": 720,
        "height": 940,
        "content": "## Reliable Structured Output from AI Agent *Without* the Structured Output Parser - with OpenAI & Switch node\n\nThis workflow serves as a **solid foundation** when you need an **AI Agent to return output in a specific JSON schema**, without relying on the often-unreliable **Structured Output Parser**.\n\n## What It Does\nThe example workflow takes a simple input (like a food item) and expects a JSON-formatted output containing its nutritional values.\n\n## Why Use This Instead of Structured Output Parser?\n\nThe built-in [Structured Output Parser](https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.outputparserstructured/common-issues/) node is known to be unreliable when working with AI Agents.\n\nWhile the **n8n documentation recommends using a \u201cBasic LLM Chain\u201d** followed by a **Structured Output Parser**, this alternative workflow **completely avoids using the Structured Output Parser node**.  \nInstead, it implements a custom loop that manually validates the AI Agent's output.\n\nThis method has **proven especially reliable** with OpenAI's `gpt-4.1` series (`gpt-4.1`, `gpt-4.1-mini`, `gpt-4.1-nano`), which tend to **produce correctly structured JSON** on the first try, as long as the **System Prompt is well defined**.\nIn this template, `gpt-4.1-nano` is set by default.\n\n### How It Works\n\nInstead of using the *Structured Output Parser*, this workflow loops the AI Agent through a manual schema validation process:\n\n- A **custom schema check** is performed after the AI Agent response.\n- A **runIndex counter** tracks the number of retries.\n- A **Switch node**:\n  - If the output does **not** match the expected schema, it routes back to the AI Agent with an updated prompt asking it to return the correct format. The process allows up to **4 retries** to avoid infinite loops.\n  - If the output **does** match the schema, it continues to a **Set node** that serves as chat response (you can customize this part to fit your use case).\n\n\nThis approach ensures schema consistency, offers flexibility, and avoids the brittleness of the default parser.\n"
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "callerPolicy": "",
    "executionOrder": "v1",
    "executionTimeout": 60
  },
  "versionId": "",
  "connections": {
    "Switch": {
      "main": [
        [
          {
            "node": "Format Schema Error Prompt",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Valid Schema Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Set schemaValidationError & lastAgentOutput",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent": {
      "main": [
        [
          {
            "node": "Validate Output + Set `aiRunIndex`",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Simple Memory": {
      "ai_memory": [
        [
          {
            "node": "AI Agent",
            "type": "ai_memory",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Format Schema Error Prompt": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When chat message received": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Output + Set `aiRunIndex`": {
      "main": [
        [
          {
            "node": "Switch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set schemaValidationError & lastAgentOutput": {
      "main": [
        [
          {
            "node": "Set chat Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}