{
  "id": "0IwIAf8uZWzk1LZq",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Answer BigQuery data questions in Microsoft Teams with Claude dual agents",
  "tags": [],
  "nodes": [
    {
      "id": "2bd40180-cb0a-4807-8daf-b8047d03cd06",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9600,
        -5504
      ],
      "parameters": {
        "width": 480,
        "height": 736,
        "content": "## Answer BigQuery data questions in Microsoft Teams with Claude dual agents\n\n### How it works\n\nThis workflow turns Microsoft Teams messages into BigQuery analysis using a dual-Claude agent pattern. It validates the incoming question, checks or refreshes BigQuery schema context, uses a main Claude agent to classify the request, then uses a SQL-focused Claude sub-agent for analytical questions. Query results are converted into a plain-English answer and posted back to Teams, while non-analytical requests and workflow errors receive separate Teams replies.\n\n### Setup steps\n\n- \u26a0\ufe0f **CRITICAL SECURITY:** Restrict the Google Cloud Service Account IAM roles to `BigQuery Data Viewer` and `BigQuery Job User` ONLY. Do not use a highly privileged account. The LLM cannot be trusted to self-police destructive queries.\n- Configure Microsoft Teams trigger and posting credentials, including the team/channel or chat where questions and replies should be handled.\n- Configure Google BigQuery credentials with permission to read INFORMATION_SCHEMA metadata and execute queries.\n- Configure Anthropic credentials for the Claude nodes.\n- Update the \"Configure Variables\" node with your BigQuery project ID and dataset allowlist."
      },
      "typeVersion": 1
    },
    {
      "id": "ab808953-89fe-41cf-969a-748bdbc4ef4d",
      "name": "Sticky Note13",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9600,
        -4704
      ],
      "parameters": {
        "color": 3,
        "width": 480,
        "height": 316,
        "content": "## \u26a0\ufe0f [PRODUCTION NOTE] Distributed Environments\n\nThis template uses n8n's `$getWorkflowStaticData()` to cache the BigQuery schema for 24 hours. This is perfect for single-instance setups and saves BigQuery API calls.\n\n**Running n8n with worker nodes (Queue Mode)?**\nSwap the schema caching logic to read/write from a dedicated Key-Value store (like Upstash Redis or Supabase) to ensure state synchronization across all distributed workers."
      },
      "typeVersion": 1
    },
    {
      "id": "ef0dfeb8-fd7a-4fdb-b2be-8ec9adc75529",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10128,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 448,
        "height": 464,
        "content": "## Receive Teams question\n\nCaptures a new Microsoft Teams message and extracts the user request into a validated format for downstream processing."
      },
      "typeVersion": 1
    },
    {
      "id": "7ad6bd29-34b6-4962-b6bc-907368648b3a",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10624,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 464,
        "content": "## Check schema cache\n\nLooks for previously cached BigQuery schema context and branches based on whether the cache can be reused."
      },
      "typeVersion": 1
    },
    {
      "id": "cd163d4a-adbf-4915-81f2-100090009540",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        11088,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 464,
        "content": "## Refresh BigQuery schema\n\nBuilds and runs the metadata query when the schema cache is missing, then compiles the returned BigQuery schema into LLM-ready context."
      },
      "typeVersion": 1
    },
    {
      "id": "5c673aae-8cc5-4a0d-9fb8-8ca85445944e",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        11760,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 464,
        "content": "## Validate schema context\n\nEnsures schema context is available from either the cache-hit path or the refreshed metadata path before prompting the main agent."
      },
      "typeVersion": 1
    },
    {
      "id": "dd4d6bc1-d87f-4055-9758-9bfdd8d44dd7",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        12224,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 576,
        "height": 464,
        "content": "## Run main Claude agent\n\nPrepares the main conversational prompt with the user request and schema context, then asks Claude to interpret and classify the request."
      },
      "typeVersion": 1
    },
    {
      "id": "b3b84df6-9c30-403f-8b3b-a6fe10949c04",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        12864,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 464,
        "height": 464,
        "content": "## Classify agent response\n\nParses Claude's main-agent output and routes the workflow depending on whether the message is an analytical data question."
      },
      "typeVersion": 1
    },
    {
      "id": "b18052ee-e5e0-4131-9281-07dae42d36e7",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        13376,
        -5104
      ],
      "parameters": {
        "color": 7,
        "width": 816,
        "height": 336,
        "content": "## Handle non-analytical request\n\nBuilds a friendly non-analytical reply and posts it back to Microsoft Teams without running BigQuery."
      },
      "typeVersion": 1
    },
    {
      "id": "de7ea659-5649-42a4-af0b-6eff73f4d49f",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        13376,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 800,
        "height": 352,
        "content": "## Generate SQL with Claude\n\nCreates the SQL-agent prompt, invokes a Claude sub-agent specialized for SQL generation, and validates/extracts the resulting query."
      },
      "typeVersion": 1
    },
    {
      "id": "454cd699-3dfc-47f9-b60d-9ada91b452a3",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        14224,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 496,
        "height": 352,
        "content": "## Execute and collect results\n\nRuns the validated SQL against BigQuery and gathers the returned rows into a structured result payload."
      },
      "typeVersion": 1
    },
    {
      "id": "3298100e-3829-49d0-a6f9-5e204d950ca8",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        14768,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 528,
        "height": 352,
        "content": "## Draft final answer\n\nPrepares a result-summary prompt and uses Claude to convert the query output into a plain-English answer."
      },
      "typeVersion": 1
    },
    {
      "id": "16b3f13f-2f30-408c-8457-c6dbcd4729f7",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        15344,
        -5504
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 352,
        "content": "## Post analytical answer\n\nFormats the generated answer as a Teams Adaptive Card and sends it to the user or channel."
      },
      "typeVersion": 1
    },
    {
      "id": "682a4a4a-b928-44b6-b009-0bfb78908bf6",
      "name": "Sticky Note12",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10128,
        -4992
      ],
      "parameters": {
        "color": 7,
        "width": 704,
        "height": 320,
        "content": "## Report workflow errors\n\nSeparate lower-left error-handling cluster that catches workflow failures, formats an error reply, and posts it to Microsoft Teams."
      },
      "typeVersion": 1
    },
    {
      "id": "ca64aa7e-eb33-432a-bdc4-56815241d547",
      "name": "When Teams Message Received",
      "type": "n8n-nodes-base.microsoftTeamsTrigger",
      "notes": "Listens for new messages in the configured Teams channel.",
      "position": [
        10192,
        -5296
      ],
      "parameters": {
        "teamId": "YOUR_TEAMS_TEAM_ID",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        }
      },
      "typeVersion": 1
    },
    {
      "id": "e5e312e5-7819-435c-8a8d-4a493aa09ffa",
      "name": "Validate Teams Message",
      "type": "n8n-nodes-base.code",
      "position": [
        10416,
        -5296
      ],
      "parameters": {
        "jsCode": "const body = $input.first().json;\nconst messageText = body.body?.content || body.text || '';\nconst cleanText = messageText.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim();\nif (!cleanText || cleanText.length < 3) throw new Error('Message too short or empty');\n\n// HUB UPDATE: Prevent the agent from responding to itself or other bots, which causes infinite API billing loops.\nconst fromUser = body.from?.user || {};\nconst fromApp = body.from?.application;\nconst isBot = fromUser.userType === 'bot' || fromUser.userType === 'application' || !!fromApp;\nif (isBot) throw new Error('Ignoring bot/application message to prevent loop');\n\nreturn [{ json: {\n  userQuestion: cleanText,\n  userId: body.from?.user?.id || 'unknown',\n  userName: body.from?.user?.displayName || 'User',\n  channelId: body.channelIdentity?.channelId || body.chatId || '',\n  teamId: body.channelIdentity?.teamId || '',\n  messageId: body.id || '',\n  timestamp: body.createdDateTime || new Date().toISOString()\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "244cdca5-9d94-4fc0-85b2-946646aa3e27",
      "name": "Configure Variables",
      "type": "n8n-nodes-base.code",
      "notes": "HUB UPDATE: Centralized configuration for easy deployment.",
      "position": [
        10656,
        -5296
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json;\n\n// \u2699\ufe0f TEMPLATE CONFIGURATION\n// Set your Google Cloud and BigQuery details here.\n// These variables are injected into downstream SQL processes.\n\nreturn [{\n  json: {\n    ...data,\n    GCP_PROJECT_ID: 'your-gcp-project-id',\n    BQ_DATASET_ID: 'your-bq-dataset-id'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a062d5da-99be-4393-b088-62c03f5fa10b",
      "name": "Check Schema Cache Validity",
      "type": "n8n-nodes-base.code",
      "notes": "Checks n8n Static Data for cached schema (TTL 24h). On hit, skips BigQuery INFORMATION_SCHEMA fetch.",
      "position": [
        10912,
        -5296
      ],
      "parameters": {
        "jsCode": "const CACHE_TTL_MS = 24 * 60 * 60 * 1000;\nconst staticData = $getWorkflowStaticData('global');\nconst userMessage = $input.first().json;\nconst now = Date.now();\nconst cacheValid = staticData.schemaCache && staticData.schemaCachedAt && (now - staticData.schemaCachedAt) < CACHE_TTL_MS;\nif (cacheValid) {\n  return [{ json: { ...userMessage, schemaText: staticData.schemaCache, tableCount: staticData.schemaTableCount || 0, columnCount: staticData.schemaColumnCount || 0, schemaFromCache: true } }];\n}\nreturn [{ json: { ...userMessage, schemaText: null, schemaFromCache: false } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "c9a1bf94-ff9c-4e95-b093-4b925852aaf6",
      "name": "If Cache Hit",
      "type": "n8n-nodes-base.if",
      "notes": "true branch = cache hit, passes through to Validate Schema Present. false branch = cache miss, fetches from BigQuery.",
      "position": [
        11120,
        -5296
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "equal"
              },
              "leftValue": "={{ $json.schemaFromCache }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "faf93e06-64eb-484b-9ad0-8a13456928e4",
      "name": "Create Schema Query Statement",
      "type": "n8n-nodes-base.code",
      "notes": "Builds the INFORMATION_SCHEMA query string using values from Configure Variables.",
      "position": [
        11344,
        -5216
      ],
      "parameters": {
        "jsCode": "const projectId = $('Configure Variables').first().json.GCP_PROJECT_ID;\nconst datasetId = $('Configure Variables').first().json.BQ_DATASET_ID;\nconst schemaQuery = [\n  'SELECT table_name, column_name, data_type, is_nullable,',\n  \"'' AS description\",\n  'FROM `' + projectId + '.' + datasetId + '.INFORMATION_SCHEMA.COLUMNS`',\n  'ORDER BY table_name, ordinal_position'\n].join(' ');\nreturn [{ json: { ...$input.first().json, schemaQuery } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "4ee5d904-cb8b-421d-a937-c1ba6c52c5ad",
      "name": "Fetch Schema from BigQuery",
      "type": "n8n-nodes-base.googleBigQuery",
      "notes": "maxResults: 5000 caps INFORMATION_SCHEMA rows before n8n memory limits are hit.",
      "position": [
        11568,
        -5216
      ],
      "parameters": {
        "options": {
          "location": "EU",
          "maxResults": 5000
        },
        "sqlQuery": "{{ $json.schemaQuery }}",
        "projectId": "={{ $('Configure Variables').first().json.GCP_PROJECT_ID }}"
      },
      "typeVersion": 2
    },
    {
      "id": "2673ed58-2da3-4595-8960-9f8e7a516edf",
      "name": "Compile Schema Data for LLM",
      "type": "n8n-nodes-base.code",
      "notes": "Builds schema text and writes to 24h static cache. Throws fatal error if payload blows out context window.",
      "position": [
        11824,
        -5216
      ],
      "parameters": {
        "jsCode": "const schemaRows = $input.all().map(i => i.json);\nconst userMessage = $('Check Schema Cache Validity').first().json;\nconst tableMap = {};\nfor (const row of schemaRows) {\n  const tbl = row.table_name;\n  if (!tableMap[tbl]) tableMap[tbl] = [];\n  tableMap[tbl].push({ column: row.column_name, type: row.data_type, nullable: row.is_nullable === 'YES', description: row.description || '' });\n}\nlet schemaText = Object.entries(tableMap).map(([table, cols]) => {\n  const colLines = cols.map(c => `    - ${c.column} (${c.type})${c.nullable ? '' : ' NOT NULL'}${c.description ? ': ' + c.description : ''}`).join('\\n');\n  return `  Table: ${table}\\n${colLines}`;\n}).join('\\n\\n');\n\nconst SCHEMA_CHAR_LIMIT = 40000;\nif (schemaText.length > SCHEMA_CHAR_LIMIT) {\n  throw new Error(`Schema payload exceeds context window limit (${schemaText.length} > 40,000 chars). Restrict your dataset allowlist in the Configure Variables node to prevent LLM hallucinations.`);\n}\n\nconst tableCount = Object.keys(tableMap).length;\nconst columnCount = schemaRows.length;\nconst staticData = $getWorkflowStaticData('global');\nstaticData.schemaCache = schemaText;\nstaticData.schemaCachedAt = Date.now();\nstaticData.schemaTableCount = tableCount;\nstaticData.schemaColumnCount = columnCount;\nreturn [{ json: { ...userMessage, schemaText, tableCount, columnCount, schemaFromCache: false } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "abd2d0d3-97e9-47e7-894d-ca33674fdf80",
      "name": "Ensure Schema Availability",
      "type": "n8n-nodes-base.code",
      "notes": "Guards schema presence before LLM invocation.",
      "position": [
        12048,
        -5312
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json;\nif (!data.schemaText) throw new Error('Schema text is missing \u2014 check Check Schema Cache and Compile Schema for LLM nodes.');\nreturn [{ json: data }];"
      },
      "typeVersion": 2
    },
    {
      "id": "918ea44b-4dff-4382-8203-ef1f662b4930",
      "name": "Build Main Agent Query Prompt",
      "type": "n8n-nodes-base.code",
      "position": [
        12320,
        -5312
      ],
      "parameters": {
        "jsCode": "const ctx = $input.first().json;\nconst prompt = `You are an intent classifier and query planner for a BigQuery data assistant.\\n\\nClassify the user message into one of three intents: analytical | conversational | unsafe\\nIf analytical: identify relevant tables and describe what SQL should be written.\\nIf unsafe: explain why in safetyNote.\\n\\nBigQuery Schema:\\n${ctx.schemaText}\\n\\nUser message: ${ctx.userQuestion}\\n\\nRespond ONLY with valid JSON \u2014 no prose, no markdown fences:\\n{\\n  \"intent\": \"analytical\",\\n  \"refinedQuestion\": \"<restatement>\",\\n  \"suggestedTables\": [\"table_name\"],\\n  \"sqlInstruction\": \"<plain English SQL description>\",\\n  \"safetyNote\": \"\"\\n}`;\nreturn [{ json: { ...ctx, _prompt: prompt } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "f3e8b86b-efbd-442c-8a0b-6926a6754f14",
      "name": "Claude Conversational Agent",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "position": [
        12544,
        -5312
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "claude-3-5-sonnet-20241022",
          "cachedResultName": "Claude 3.5 Sonnet"
        },
        "options": {
          "maxTokens": 512,
          "temperature": 0.1
        },
        "messages": {
          "values": [
            {
              "content": "={{ $json._prompt }}"
            }
          ]
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "504d971f-5406-42d3-9654-5e302e0bf7a1",
      "name": "Parse Claude Agent Output",
      "type": "n8n-nodes-base.code",
      "notes": "Checks raw.error before parsing so API errors surface cleanly.",
      "position": [
        12912,
        -5312
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json;\n\nif (raw.error) {\n  throw new Error(`Anthropic API error on intent classification: ${raw.error.type} \u2014 ${raw.error.message}`);\n}\n\nconst rawText = raw.text || raw.content?.[0]?.text || '';\nif (!rawText) throw new Error('Main Agent returned an empty response.');\nlet agentResponse;\ntry {\n  const jsonMatch = rawText.match(/\\{[\\s\\S]*\\}/);\n  if (!jsonMatch) throw new Error('No JSON object found in response');\n  agentResponse = JSON.parse(jsonMatch[0]);\n} catch (e) {\n  throw new Error(`Main Agent unparseable: ${rawText.substring(0, 400)} | ${e.message}`);\n}\nconst intent = (agentResponse.intent || 'conversational').toLowerCase().trim();\nconst validIntents = ['analytical', 'conversational', 'unsafe'];\nagentResponse.intent = validIntents.includes(intent) ? intent : 'conversational';\nconst context = $('Build Main Agent Query Prompt').first().json;\nreturn [{ json: {\n  ...context,\n  agentIntent: agentResponse.intent,\n  safetyNote: agentResponse.safetyNote || '',\n  refinedQuestion: agentResponse.refinedQuestion || context.userQuestion,\n  suggestedTables: agentResponse.suggestedTables || [],\n  sqlInstruction: agentResponse.sqlInstruction || ''\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "de33bdc2-495d-4cdc-85da-5a2d3703d12d",
      "name": "If Analytical Intent Detected",
      "type": "n8n-nodes-base.if",
      "notes": "true branch \u2192 Prepare SQL Agent Prompt. false branch \u2192 Build Non-Analytical Reply.",
      "position": [
        13136,
        -5312
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.agentIntent }}",
              "rightValue": "analytical"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "6f4ca66a-634c-4138-921d-a2e4d907336c",
      "name": "Build SQL Agent Prompt",
      "type": "n8n-nodes-base.code",
      "notes": "Trims schema to suggestedTables only before sending to SQL agent, reducing token cost.",
      "position": [
        13424,
        -5328
      ],
      "parameters": {
        "jsCode": "const ctx = $input.first().json;\n\nconst projectId = $('Configure Variables').first().json.GCP_PROJECT_ID;\nconst datasetId = $('Configure Variables').first().json.BQ_DATASET_ID;\n\nlet schemaForSQL = ctx.schemaText;\nif (ctx.suggestedTables && ctx.suggestedTables.length > 0) {\n  const lines = ctx.schemaText.split('\\n');\n  const relevant = [];\n  let inRelevantTable = false;\n  for (const line of lines) {\n    const tableMatch = line.match(/^  Table: (.+)$/);\n    if (tableMatch) {\n      inRelevantTable = ctx.suggestedTables.includes(tableMatch[1].trim());\n    }\n    if (inRelevantTable) relevant.push(line);\n  }\n  if (relevant.length > 0) schemaForSQL = relevant.join('\\n');\n}\n\nconst prompt = `You are a BigQuery SQL expert. Generate a safe, read-only SELECT query.\\n\\nRULES:\\n- Only SELECT statements permitted\\n- Use fully-qualified table names: \\`${projectId}.${datasetId}.table_name\\`\\n- Standard SQL only (not Legacy SQL)\\n\\nSchema (relevant tables only):\\n${schemaForSQL}\\n\\nUser question: ${ctx.refinedQuestion}\\nInstruction: ${ctx.sqlInstruction}\\nSuggested tables: ${(ctx.suggestedTables || []).join(', ')}\\n\\nRespond ONLY with valid JSON \u2014 no prose, no markdown fences:\\n{\\n  \"sql\": \"<complete SQL query>\",\\n  \"queryDescription\": \"<one sentence>\"\\n}`;\nreturn [{ json: { ...ctx, _prompt: prompt, _projectId: projectId, _datasetId: datasetId } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "8f2a2972-80ca-490e-b7b4-9da291505436",
      "name": "Claude SQL Sub-Agent",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "position": [
        13648,
        -5328
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "claude-3-5-sonnet-20241022",
          "cachedResultName": "Claude 3.5 Sonnet"
        },
        "options": {
          "maxTokens": 1024,
          "temperature": 0
        },
        "messages": {
          "values": [
            {
              "content": "={{ $json._prompt }}"
            }
          ]
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "1f562c11-69bf-462b-9390-720b136b2e14",
      "name": "Extract and Validate SQL Code",
      "type": "n8n-nodes-base.code",
      "notes": "Regex blocklist removed. Security delegates to proper GCP IAM constraints.",
      "position": [
        14000,
        -5328
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json;\n\nif (raw.error) {\n  throw new Error(`Anthropic API error on SQL generation: ${raw.error.type} \u2014 ${raw.error.message}`);\n}\n\nconst rawText = raw.text || raw.content?.[0]?.text || '';\nif (!rawText) throw new Error('SQL Sub-Agent returned an empty response.');\nlet sqlResponse;\ntry {\n  const jsonMatch = rawText.match(/\\{[\\s\\S]*\\}/);\n  if (!jsonMatch) throw new Error('No JSON object found in SQL agent response');\n  sqlResponse = JSON.parse(jsonMatch[0]);\n} catch (e) {\n  throw new Error(`SQL Sub-Agent unparseable: ${rawText.substring(0, 400)} | ${e.message}`);\n}\nconst sql = (sqlResponse.sql || '').trim();\nif (!sql) throw new Error('SQL Sub-Agent returned an empty SQL string.');\n\n// Security relies strictly on Google Cloud IAM roles (BigQuery Data Viewer).\n// Row volume limits are handled natively by maxResults in the downstream BigQuery execution node.\n\nconst context = $('Build SQL Agent Prompt').first().json;\nreturn [{ json: { ...context, generatedSQL: sql, queryDescription: sqlResponse.queryDescription || '' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "41e28301-780f-4e1d-ac14-97fc87b0038f",
      "name": "Run BigQuery SQL Query",
      "type": "n8n-nodes-base.googleBigQuery",
      "notes": "maxResults: 100 hard cap enforced at the BigQuery API level. alwaysOutputData: true prevents workflow halting on zero results.",
      "position": [
        14320,
        -5328
      ],
      "parameters": {
        "options": {
          "location": "EU",
          "maxResults": 100
        },
        "sqlQuery": "{{ $json.generatedSQL }}",
        "projectId": "={{ $('Configure Variables').first().json.GCP_PROJECT_ID }}"
      },
      "typeVersion": 2,
      "alwaysOutputData": true
    },
    {
      "id": "9c825217-6d7b-4b98-8541-587a824df828",
      "name": "Gather SQL Query Results",
      "type": "n8n-nodes-base.code",
      "position": [
        14544,
        -5328
      ],
      "parameters": {
        "jsCode": "const allRows = $input.all().map(i => i.json);\nconst context = $('Extract and Validate SQL Code').first().json;\n\n// Filter out the empty object generated by 'alwaysOutputData' when 0 rows are returned\nconst validRows = allRows.filter(row => Object.keys(row).length > 0);\n\nconst rowCount = validRows.length;\nconst truncated = rowCount >= 100;\nconst rowsForLLM = validRows.slice(0, 50);\n\nreturn [{ json: { ...context, queryResults: rowsForLLM, totalRowsReturned: rowCount, truncated, hasResults: rowCount > 0 } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "2c70f138-41b4-4d5b-9458-bd22b2511cb7",
      "name": "Formulate Response Prompt",
      "type": "n8n-nodes-base.code",
      "position": [
        14816,
        -5328
      ],
      "parameters": {
        "jsCode": "const ctx = $input.first().json;\nconst prompt = `You are a business intelligence assistant. Summarise these BigQuery results in plain English for a non-technical Teams user.\\n\\nUser question: ${ctx.userQuestion}\\nQuery description: ${ctx.queryDescription}\\nTotal rows: ${ctx.totalRowsReturned}${ctx.truncated ? ' (truncated at 100)' : ''}\\n\\nData:\\n${JSON.stringify(ctx.queryResults, null, 2)}\\n\\nInstructions:\\n- Lead with a direct answer\\n- Highlight key numbers, trends, or outliers\\n- If no data, say so and suggest why\\n- Max 150 words\\n- Never mention SQL, BigQuery, or technical internals`;\nreturn [{ json: { ...ctx, _prompt: prompt } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "9c9a1fe6-c9b1-4333-abb2-d78035346707",
      "name": "Claude Generates Response",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "position": [
        15040,
        -5328
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "claude-3-5-sonnet-20241022",
          "cachedResultName": "Claude 3.5 Sonnet"
        },
        "options": {
          "maxTokens": 512,
          "temperature": 0.1
        },
        "messages": {
          "values": [
            {
              "content": "={{ $json._prompt }}"
            }
          ]
        }
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "8d1dcfbb-92aa-42fe-8994-da50aee49cff",
      "name": "Create Teams Adaptive Card",
      "type": "n8n-nodes-base.code",
      "notes": "Data preview utilizes FactSet layout instead of raw key:value monospace string.",
      "position": [
        15408,
        -5328
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json;\nif (raw.error) throw new Error(`Anthropic API error on answer synthesis: ${raw.error.type} \u2014 ${raw.error.message}`);\nconst answer = raw.text || raw.content?.[0]?.text || 'Could not generate an answer.';\nconst context = $('Formulate Response Prompt').first().json;\nconst rows = (context.queryResults || []).slice(0, 5);\n\nconst previewBlocks = [];\nif (rows.length === 0) {\n  previewBlocks.push({ type: 'TextBlock', text: 'No data returned.', color: 'Warning', spacing: 'Small' });\n} else {\n  const firstRowFacts = Object.entries(rows[0]).map(([k, v]) => ({ title: k, value: String(v ?? '') }));\n  previewBlocks.push({ type: 'FactSet', facts: firstRowFacts, spacing: 'Small' });\n  for (let i = 1; i < rows.length; i++) {\n    const line = Object.entries(rows[i]).map(([k, v]) => `${k}: ${v ?? ''}`).join(' | ');\n    previewBlocks.push({ type: 'TextBlock', text: line, wrap: true, fontType: 'Monospace', size: 'Small', color: 'Default', spacing: 'None' });\n  }\n  if (context.totalRowsReturned > rows.length) {\n    previewBlocks.push({ type: 'TextBlock', text: `\u2026and ${context.totalRowsReturned - rows.length} more rows`, size: 'Small', color: 'Light', spacing: 'None', isSubtle: true });\n  }\n}\n\nconst sqlPreview = (context.generatedSQL || '').substring(0, 150);\nconst sqlDisplay = (context.generatedSQL || '').length > 150 ? sqlPreview + '...' : sqlPreview;\nconst card = {\n  type: 'message',\n  attachments: [{\n    contentType: 'application/vnd.microsoft.card.adaptive',\n    content: {\n      '$schema': 'http://adaptivecards.io/schemas/adaptive-card.json',\n      type: 'AdaptiveCard',\n      version: '1.4',\n      body: [\n        { type: 'TextBlock', text: 'BigQuery Analyst', weight: 'Bolder', size: 'Medium', color: 'Accent' },\n        { type: 'TextBlock', text: `**Q:** ${context.userQuestion}`, wrap: true, spacing: 'Small' },\n        { type: 'TextBlock', text: answer, wrap: true, spacing: 'Medium' },\n        { type: 'TextBlock', text: `Data preview (${context.totalRowsReturned} rows${context.truncated ? ', truncated at 100' : ''})`, weight: 'Bolder', spacing: 'Medium', size: 'Small' },\n        ...previewBlocks,\n        { type: 'TextBlock', text: `SQL: \\`${sqlDisplay}\\``, wrap: true, size: 'Small', color: 'Light', spacing: 'Medium' }\n      ]\n    }\n  }]\n};\nreturn [{ json: { card, channelId: context.channelId, teamId: context.teamId } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "2cabe382-12fa-4d9a-9500-6bc1c61517d4",
      "name": "Send Answer to Teams Channel",
      "type": "n8n-nodes-base.microsoftTeams",
      "position": [
        15632,
        -5328
      ],
      "parameters": {
        "operation": "sendMessage"
      },
      "typeVersion": 2
    },
    {
      "id": "9076321e-02c6-4e99-b689-78a6d522ede9",
      "name": "Generate Non-Analytical Response",
      "type": "n8n-nodes-base.code",
      "position": [
        13568,
        -4960
      ],
      "parameters": {
        "jsCode": "const context = $('Parse Claude Agent Output').first().json;\nconst isUnsafe = context.agentIntent === 'unsafe';\nconst message = isUnsafe\n  ? `Sorry, I can't run that query. ${context.safetyNote || 'That request is outside what I can help with.'} Please ask a read-only analytical question about your data.`\n  : `I can only answer analytical questions about your BigQuery data. Examples:\\n- \"What were total sales last month?\"\\n- \"Which customers had the highest order value this week?\"\\n- \"How many new signups did we get in June?\"`;\nreturn [{ json: { channelId: context.channelId, teamId: context.teamId, message } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "39303412-c908-4181-ac80-a7a1e9115201",
      "name": "Share Non-Analytical Reply to Teams",
      "type": "n8n-nodes-base.microsoftTeams",
      "position": [
        13856,
        -4960
      ],
      "parameters": {
        "operation": "sendMessage"
      },
      "typeVersion": 2
    },
    {
      "id": "b4356c3a-5f5c-4dc5-aa21-de04999ad6d1",
      "name": "Format Error Message",
      "type": "n8n-nodes-base.code",
      "notes": "Error handler node. Extracts channelId/teamId from execution data to post the failure message.",
      "position": [
        10432,
        -4832
      ],
      "parameters": {
        "jsCode": "const err = $input.first().json;\nconst nodeName = err.node?.name || 'unknown node';\nconst runData = err.execution?.data?.resultData?.runData || {};\nconst firstRun = runData['Validate Teams Message']?.[0]?.data?.main?.[0]?.[0]?.json || {};\nreturn [{ json: {\n  channelId: firstRun.channelId || '',\n  teamId: firstRun.teamId || '',\n  message: `Something went wrong (failed at: ${nodeName}). Please try again. If this persists, contact your administrator.`\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "bbfd43ac-454c-4a9f-a74e-7347a0cdd852",
      "name": "Send Error Notification to Teams",
      "type": "n8n-nodes-base.microsoftTeams",
      "position": [
        10656,
        -4832
      ],
      "parameters": {
        "operation": "sendMessage"
      },
      "typeVersion": 2
    },
    {
      "id": "e4157e62-4fe8-4d6d-830e-42693750f198",
      "name": "On Workflow Error Occurrence",
      "type": "n8n-nodes-base.errorTrigger",
      "notes": "REQUIRED SETUP: Go to Workflow Settings \u2192 Error Workflow and select this same workflow as the error handler.",
      "position": [
        10208,
        -4832
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "90f1ec0e-f0b6-4b4f-9e15-3b2c18ac5b94",
  "nodeGroups": [],
  "connections": {
    "If Cache Hit": {
      "main": [
        [
          {
            "node": "Ensure Schema Availability",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create Schema Query Statement",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure Variables": {
      "main": [
        [
          {
            "node": "Check Schema Cache Validity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude SQL Sub-Agent": {
      "main": [
        [
          {
            "node": "Extract and Validate SQL Code",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Error Message": {
      "main": [
        [
          {
            "node": "Send Error Notification to Teams",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build SQL Agent Prompt": {
      "main": [
        [
          {
            "node": "Claude SQL Sub-Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run BigQuery SQL Query": {
      "main": [
        [
          {
            "node": "Gather SQL Query Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Teams Message": {
      "main": [
        [
          {
            "node": "Configure Variables",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gather SQL Query Results": {
      "main": [
        [
          {
            "node": "Formulate Response Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Generates Response": {
      "main": [
        [
          {
            "node": "Create Teams Adaptive Card",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Formulate Response Prompt": {
      "main": [
        [
          {
            "node": "Claude Generates Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Claude Agent Output": {
      "main": [
        [
          {
            "node": "If Analytical Intent Detected",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Teams Adaptive Card": {
      "main": [
        [
          {
            "node": "Send Answer to Teams Channel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ensure Schema Availability": {
      "main": [
        [
          {
            "node": "Build Main Agent Query Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Schema from BigQuery": {
      "main": [
        [
          {
            "node": "Compile Schema Data for LLM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Schema Cache Validity": {
      "main": [
        [
          {
            "node": "If Cache Hit",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude Conversational Agent": {
      "main": [
        [
          {
            "node": "Parse Claude Agent Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compile Schema Data for LLM": {
      "main": [
        [
          {
            "node": "Ensure Schema Availability",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Teams Message Received": {
      "main": [
        [
          {
            "node": "Validate Teams Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "On Workflow Error Occurrence": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Main Agent Query Prompt": {
      "main": [
        [
          {
            "node": "Claude Conversational Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Schema Query Statement": {
      "main": [
        [
          {
            "node": "Fetch Schema from BigQuery",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract and Validate SQL Code": {
      "main": [
        [
          {
            "node": "Run BigQuery SQL Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Analytical Intent Detected": {
      "main": [
        [
          {
            "node": "Build SQL Agent Prompt",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Generate Non-Analytical Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Non-Analytical Response": {
      "main": [
        [
          {
            "node": "Share Non-Analytical Reply to Teams",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}