This workflow follows the Google Sheets → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "02 - Claude API Single Record Analysis v2 RESILIENT ROUTER PRODUCTION",
"nodes": [
{
"id": "rr000000-0000-0000-0000-0000000000p1",
"name": "Production Notes",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
80,
-120
],
"parameters": {
"content": "RESILIENT ROUTER \u2014 PRODUCTION (single record).\n\nBEFORE RUN:\n1. Set Google Sheets credential + real Spreadsheet ID on 'Append to Dynamic Route Sheet'.\n2. Create 6 tabs: results, review_queue, monitor_queue, content_queue, skipped_log, technical_errors (headers per docs/TABLE_SCHEMA.md, 33 production columns).\n3. Replace PLACEHOLDER_TEXT_REPLACE_BEFORE_RUN in 'Set Source Record' with a real record.\n\nNo test/mock fields. raw_response_preview capped at 500 chars. Route validation forces technical_errors on bad route. active=false \u2014 manual run only.",
"height": 260,
"width": 460,
"color": 4
}
},
{
"id": "rr000000-0000-0000-0000-000000000002",
"name": "Manual Start",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
120,
300
],
"parameters": {}
},
{
"id": "rr000000-0000-0000-0000-0000000000s1",
"name": "Set Source Record",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
340,
300
],
"parameters": {
"mode": "manual",
"assignments": {
"assignments": [
{
"id": "src-01",
"name": "source_type",
"value": "scraped_web",
"type": "string"
},
{
"id": "src-02",
"name": "platform",
"value": "website",
"type": "string"
},
{
"id": "src-03",
"name": "source_url",
"value": "https://example.com/source",
"type": "string"
},
{
"id": "src-04",
"name": "profile_url",
"value": "",
"type": "string"
},
{
"id": "src-05",
"name": "published_at",
"value": "",
"type": "string"
},
{
"id": "src-06",
"name": "parsed_at",
"value": "={{ $today }}",
"type": "string"
},
{
"id": "src-07",
"name": "text_context",
"value": "\u0417\u0430\u0439\u043c\u044b \u043f\u043e\u0434 \u0437\u0430\u043b\u043e\u0433 \u041f\u0422\u0421 \u0438 \u0430\u0432\u0442\u043e\u043c\u043e\u0431\u0438\u043b\u044f \u0432 \u041c\u043e\u0441\u043a\u0432\u0435 \u0438 \u041c\u041e. \u0421\u0442\u0430\u0432\u043a\u0430 \u043e\u0442 2,5% \u0432 \u043c\u0435\u0441\u044f\u0446. \u041e\u0434\u043e\u0431\u0440\u0435\u043d\u0438\u0435 \u0437\u0430 1 \u0447\u0430\u0441. \u0420\u0430\u0431\u043e\u0442\u0430\u0435\u043c \u0441 \u043b\u044e\u0431\u043e\u0439 \u043a\u0440\u0435\u0434\u0438\u0442\u043d\u043e\u0439 \u0438\u0441\u0442\u043e\u0440\u0438\u0435\u0439. \u0421\u0443\u043c\u043c\u0430 \u043e\u0442 50 000 \u0434\u043e 1 000 000 \u0440\u0443\u0431\u043b\u0435\u0439. \u0422\u0435\u043b\u0435\u0444\u043e\u043d +7(499)555-00-11.",
"type": "string"
}
]
}
}
},
{
"id": "rr000000-0000-0000-0000-000000000005",
"name": "Build Primary Claude Request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
560,
300
],
"parameters": {
"jsCode": "const record = $json;\n\nconst systemPrompt = `You are Marketing Scout Agent v2 -- a market intelligence analyst for a secured lending business in Moscow and Moscow Oblast, Russia.\n\nFor every record ask: What does this mean for the operator's business and what should they do? Reason like a business owner.\n\nANALYSIS PRIORITY ORDER:\n1. LEAD SIGNAL first -- potential client needing secured loan? Moscow/MO? PTS/auto/real estate collateral? Urgency? Contactable?\n2. COMPETITOR second -- active secured lending business Moscow/MO? Threat level?\n3. CONTENT IDEA third -- client fear/objection/knowledge gap for secured lending?\n4. IRRELEVANT if none apply.\n\nIDEAL CLIENT: Car owner (PTS clean title), Moscow/MO, needs cash urgently, bank-rejected, 50k-500k RUB.\n\nHigh-urgency signals (raise lead_signal_score): \u0441\u0440\u043e\u0447\u043d\u043e, \u0441\u0435\u0433\u043e\u0434\u043d\u044f, \u0431\u0430\u043d\u043a\u0438 \u043e\u0442\u043a\u0430\u0437\u0430\u043b\u0438, \u043d\u0435 \u0434\u0430\u044e\u0442 \u043a\u0440\u0435\u0434\u0438\u0442, \u0438\u0441\u043f\u043e\u0440\u0447\u0435\u043d\u0430 \u043a\u0440\u0435\u0434\u0438\u0442\u043d\u0430\u044f \u0438\u0441\u0442\u043e\u0440\u0438\u044f + specific amount + collateral type.\n\nREGION RULES:\n- Moscow/MO explicit: lead_signal_score 60-100\n- Region ambiguous: eligible up to 55\n- Another city/region: lead_signal_score capped at 40\nCompetitors: Moscow/MO or national coverage -> score normally; other region only -> cap competitor_strength at 50.\n\nlead_signal_score calibration:\n- 85-100: fit + urgency + readiness + Moscow/MO confirmed\n- 70-84: strong fit+urgency, region confirmed, readiness partial\n- 55-69: clear product fit, one signal confirmed, region present\n- 35-54: intent apparent, fit unclear or region outside MO\n- 1-34: no real lead signal\n\nrecommended_action=contact requires lead_signal_score>=70.\n\ncompetitor_strength calibration:\n- 85-100: fresh (<=30d), Moscow/MO, stated rate, same-day, bad-credit accepted, contactable\n- 65-84: active professional, Moscow/MO confirmed, rate absent or one signal missing\n- 45-64: present but older content or uncertain coverage\n- 25-44: weak - stale or different region\n- 1: not a competitor\n\nSKIP RULES -- return status=skipped, quality_score=1, all scores=1 when:\n- Fewer than 40 meaningful chars\n- Pure navigation boilerplate\n- No connection to financial services\n- published_at >180 days before parsed_at with no fresh signals\n\nREASON FIELD (3 sentences required):\n1. WHAT: what is this record, key evidence from text\n2. WHY: why scores are what they are, cite specific signals\n3. NEXT: what operator should do and why\n\nOUTPUT FORMAT -- CRITICAL:\nRespond with ONLY a valid JSON object.\nNo markdown, no code fences, no preamble.\nFirst character must be {. Last must be }.\nAll 25 fields required. Empty string for unknown strings. 1 for unknown integers.\n\nREQUIRED JSON SCHEMA:\n{\n \"created_at\": \"<parsed_at value ISO 8601>\",\n \"source_type\": \"<from input>\",\n \"platform\": \"<from input>\",\n \"source_url\": \"<from input>\",\n \"parsed_at\": \"<from input>\",\n \"published_at\": \"<from input or empty>\",\n \"freshness_status\": \"<fresh|recent|old|unknown>\",\n \"entity_type\": \"<competitor|lead_signal|market_signal|content_idea|irrelevant>\",\n \"company_name\": \"<explicitly in text only or empty>\",\n \"profile_name\": \"<explicitly in text only or empty>\",\n \"profile_url\": \"<from input or empty>\",\n \"region\": \"<explicitly mentioned or empty>\",\n \"service_type\": \"<secured_auto_loan|secured_real_estate_loan|pts_loan|refinancing|mortgage_adjacent|generic_lending|unknown>\",\n \"offer_text\": \"<1 sentence: what offered/sought or content angle title>\",\n \"terms\": \"<explicit rate/conditions only or empty>\",\n \"contact_public\": \"<phone/email/Telegram from text only or empty>\",\n \"text_context\": \"<cleaned summary max 300 chars>\",\n \"detected_need\": \"<lead_signal only: need+amount+urgency+bank rejection+region or empty>\",\n \"competitor_strength\": <integer 1-100; 1 if not competitor>,\n \"lead_signal_score\": <integer 1-100>,\n \"content_idea_score\": <integer 1-100>,\n \"quality_score\": <integer 1-100>,\n \"reason\": \"<3 sentences: what+evidence; why scores; next action>\",\n \"recommended_action\": \"<monitor|contact|create_content|ignore|investigate>\",\n \"status\": \"<analyzed|skipped>\"\n}\n\nREMINDER: Return JSON only. No markdown. No analysis outside JSON. For competitor website records, classify entity_type=competitor if the text offers secured lending services, rates, speed, contact, or Moscow/MO coverage.`;\n\nreturn [{ json: {\n model: 'claude-sonnet-4-6',\n max_tokens: 1400,\n temperature: 0.2,\n system: systemPrompt,\n messages: [{\n role: 'user',\n content: JSON.stringify({\n source_type: record.source_type || '',\n platform: record.platform || '',\n source_url: record.source_url || '',\n profile_url: record.profile_url || '',\n parsed_at: record.parsed_at || '',\n published_at: record.published_at || '',\n text_context: record.text_context || ''\n })\n }]\n}}];"
}
},
{
"id": "rr000000-0000-0000-0000-000000000007",
"name": "Claude Primary API Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
820,
300
],
"onError": "continueRegularOutput",
"parameters": {
"method": "POST",
"url": "https://aiprimetech.io/v1/messages",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "anthropic-version",
"value": "2023-06-01"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/json",
"body": "={{ JSON.stringify({ model: $json.model, max_tokens: $json.max_tokens, temperature: $json.temperature, system: $json.system, messages: $json.messages }) }}",
"options": {}
},
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
}
},
{
"id": "rr000000-0000-0000-0000-000000000008",
"name": "Parse Primary JSON",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1080,
300
],
"parameters": {
"jsCode": "const response = $json;\nconst srcRecord = $('Set Source Record').first().json;\nfunction cap(s, n) { return (s == null ? '' : String(s)).substring(0, n); }\n\n// Handle HTTP error responses \u2014 preserve everything\nif (response.error || response.status >= 400) {\n const err = 'Primary HTTP error: ' + cap(JSON.stringify(response.error || response.status), 300);\n const prev = cap(JSON.stringify(response), 500);\n return [{ json: {\n parse_ok: false, parse_method: 'primary_json', repair_used: false,\n processing_status: 'technical_error_candidate',\n primary_parse_error: err,\n primary_raw_response_preview: prev,\n content_summary: 'http_error',\n parse_error: err,\n raw_response_preview: prev,\n original_record: srcRecord\n }}];\n}\n\n// Find text item\nlet textItem;\nlet contentSummary = '';\ntry {\n if (!response.content || !Array.isArray(response.content)) throw new Error('no content array');\n contentSummary = response.content.map(c => c.type).join(',');\n textItem = response.content.find(c => c.type === 'text');\n} catch(e) {\n const err = 'Primary parse failed: no content array: ' + e.message;\n const prev = cap(JSON.stringify(response), 500);\n return [{ json: {\n parse_ok: false, parse_method: 'primary_json', repair_used: false,\n processing_status: 'technical_error_candidate',\n primary_parse_error: err,\n primary_raw_response_preview: prev,\n content_summary: 'no_content_array',\n parse_error: err,\n raw_response_preview: prev,\n original_record: srcRecord\n }}];\n}\n\nif (!textItem || !textItem.text) {\n const err = 'Primary parse failed: no text item';\n const prev = cap(JSON.stringify(response.content || response), 500);\n return [{ json: {\n parse_ok: false, parse_method: 'primary_json', repair_used: false,\n processing_status: 'technical_error_candidate',\n primary_parse_error: err,\n primary_raw_response_preview: prev,\n content_summary: contentSummary || 'no_text_item',\n parse_error: err,\n raw_response_preview: prev,\n original_record: srcRecord\n }}];\n}\n\nconst rawPreview = cap(textItem.text, 500);\n\n// Parse with defensive cleanup\nlet parsed;\nlet candidate = '';\ntry {\n let txt = textItem.text.trim();\n txt = txt.replace(/^```json\\s*/i, '').replace(/\\s*```$/,'').trim();\n txt = txt.replace(/^```\\s*/,'').replace(/\\s*```$/,'').trim();\n const bStart = txt.indexOf('{');\n const bEnd = txt.lastIndexOf('}');\n if (bStart !== -1 && bEnd !== -1) txt = txt.substring(bStart, bEnd + 1);\n candidate = txt;\n txt = txt.replace(/[\u2018\u2019]/g, \"'\").replace(/[\u201c\u201d\u00ab\u00bb]/g, '\"');\n parsed = JSON.parse(txt);\n} catch(e) {\n const err = 'Primary JSON parse failed: ' + e.message;\n const prev = cap(candidate || rawPreview, 500);\n return [{ json: {\n parse_ok: false, parse_method: 'primary_json', repair_used: false,\n processing_status: 'technical_error_candidate',\n primary_parse_error: err,\n primary_raw_response_preview: prev,\n content_summary: 'text_present_json_invalid',\n parse_error: err,\n raw_response_preview: prev,\n original_record: srcRecord\n }}];\n}\n\nreturn [{ json: {\n parse_ok: true,\n parse_method: 'primary_json',\n repair_used: false,\n repair_status: '',\n parse_error: '',\n raw_response_preview: rawPreview,\n ...parsed\n}}];"
}
},
{
"id": "rr000000-0000-0000-0000-000000000009",
"name": "IF Primary Parse OK?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1340,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "rr000000-0000-0000-0000-000000000091",
"leftValue": "={{ $json.parse_ok }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
}
}
},
{
"id": "rr000000-0000-0000-0000-000000000010",
"name": "Build Repair Request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1340,
560
],
"parameters": {
"jsCode": "const input = $json;\nconst srcRecord = $('Set Source Record').first().json;\nfunction cap(s, n) { return (s == null ? '' : String(s)).substring(0, n); }\n\nconst rawPreview = cap(input.primary_raw_response_preview || input.raw_response_preview || 'empty', 500);\nconst primaryErr = cap(input.primary_parse_error || input.parse_error || 'Primary parse failed', 300);\n\nconst repairSystem = `You are a JSON repair formatter, not a market analyst. Convert the raw response into strict JSON. Do not invent facts. If raw response is unusable, return an object that can be routed to technical_errors.\nReturn JSON only. No markdown. First char {, last char }.\nFields: created_at, source_type, platform, source_url, parsed_at, published_at, freshness_status, entity_type, company_name, profile_name, profile_url, region, service_type, offer_text, terms, contact_public, text_context, detected_need, competitor_strength, lead_signal_score, content_idea_score, quality_score, reason, recommended_action, status.\nUnknown text -> \"\". Unknown score -> 1. Unusable -> status=skipped, entity_type=irrelevant, recommended_action=ignore, all scores=1.\nEnums: entity_type[competitor,lead_signal,market_signal,content_idea,irrelevant]; recommended_action[monitor,contact,create_content,ignore,investigate]; status[analyzed,skipped]; freshness_status[fresh,recent,old,unknown].`;\n\nconst userMsg = JSON.stringify({\n original_record: {\n source_type: srcRecord.source_type || '',\n platform: srcRecord.platform || '',\n source_url: srcRecord.source_url || '',\n profile_url: srcRecord.profile_url || '',\n parsed_at: srcRecord.parsed_at || '',\n published_at: srcRecord.published_at || '',\n text_context: cap(srcRecord.text_context || '', 500)\n },\n raw_primary_response: rawPreview,\n primary_parse_error: primaryErr\n});\n\nreturn [{ json: {\n model: 'claude-sonnet-4-6',\n max_tokens: 700,\n temperature: 0,\n system: repairSystem,\n messages: [{ role: 'user', content: userMsg }]\n}}];"
}
},
{
"id": "rr000000-0000-0000-0000-000000000011",
"name": "Claude Repair API Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1620,
560
],
"onError": "continueRegularOutput",
"parameters": {
"method": "POST",
"url": "https://aiprimetech.io/v1/messages",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "anthropic-version",
"value": "2023-06-01"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/json",
"body": "={{ JSON.stringify({ model: $json.model, max_tokens: $json.max_tokens, temperature: $json.temperature, system: $json.system, messages: $json.messages }) }}",
"options": {}
},
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
}
},
{
"id": "rr000000-0000-0000-0000-000000000012",
"name": "Parse Repaired JSON",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1900,
560
],
"parameters": {
"jsCode": "const response = $json;\nconst primary = $('Parse Primary JSON').first().json;\nfunction cap(s, n) { return (s == null ? '' : String(s)).substring(0, n); }\n\nconst primaryErr = cap(primary.primary_parse_error || primary.parse_error || 'unknown', 300);\nconst primaryPreview = cap(primary.primary_raw_response_preview || primary.raw_response_preview || '', 500);\n\nfunction technicalError(repairErr) {\n const combined = cap('Primary: ' + primaryErr + ' | Repair: ' + repairErr, 800);\n // preserve primary raw preview first; append repair error only if space allows\n let preview = primaryPreview;\n if (preview.length < 440) {\n preview = cap(preview + ' || REPAIR_ERR: ' + repairErr, 500);\n }\n return [{ json: {\n parse_ok: false,\n parse_method: 'technical_error',\n repair_used: true,\n repair_status: 'failed',\n processing_status: 'technical_error',\n route: 'technical_errors',\n needs_manual_review: true,\n parse_error: combined,\n raw_response_preview: cap(preview, 500),\n original_record: primary.original_record || {}\n }}];\n}\n\n// Handle HTTP error from repair call\nif (response.error || response.status >= 400) {\n return technicalError('Repair HTTP error: ' + cap(JSON.stringify(response.error || response.status), 300));\n}\n\nlet textItem;\ntry {\n textItem = (response.content || []).find(c => c.type === 'text');\n} catch(e) { textItem = null; }\n\nif (!textItem || !textItem.text) {\n return technicalError('No text item in repair response');\n}\n\nconst rawPreview = cap(textItem.text, 500);\n\nlet parsed;\ntry {\n let txt = textItem.text.trim();\n txt = txt.replace(/^```json\\s*/i,'').replace(/\\s*```$/,'').trim();\n txt = txt.replace(/^```\\s*/,'').replace(/\\s*```$/,'').trim();\n const bStart = txt.indexOf('{'); const bEnd = txt.lastIndexOf('}');\n if (bStart !== -1 && bEnd !== -1) txt = txt.substring(bStart, bEnd + 1);\n txt = txt.replace(/[\u2018\u2019]/g,\"'\").replace(/[\u201c\u201d\u00ab\u00bb]/g,'\"');\n parsed = JSON.parse(txt);\n} catch(e) {\n return technicalError('Repair JSON parse failed: ' + e.message);\n}\n\nreturn [{ json: {\n parse_ok: true,\n parse_method: 'repaired_json',\n repair_used: true,\n repair_status: 'success',\n parse_error: '',\n raw_response_preview: rawPreview,\n ...parsed\n}}];"
}
},
{
"id": "rr000000-0000-0000-0000-000000000013",
"name": "Normalize + Route",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2160,
300
],
"parameters": {
"jsCode": "// embedded n8n/lib/error_sanitizer.js (drift-proof; test asserts equality)\n// error_sanitizer.js \u2014 what may be persisted about a FAILED record (technical_errors / skipped_log routes).\n//\n// WF04-ROUTE-002. The resilient routers persist `raw_response_preview` + `parse_error` for triage. Those strings are\n// built from a provider response, so without a gate they can carry an Authorization header, an api key, a cookie, a\n// Claude thinking block, or a customer's phone/email straight into a durable Google Sheets tab that many people can\n// open. Diagnosis needs a bounded, scrubbed EXCERPT \u2014 never the raw body.\n//\n// Keep: provider, source URL, safe error category, bounded sanitized excerpt, request/run lineage, timestamp.\n// Drop: secrets, credentials, cookies, hidden reasoning, private PII, anything past the cap.\n//\n// Embeddable: unique es*-prefixed names, no cross-lib require.\n\nfunction esStr(v) { return v == null ? '' : String(v); }\n\nvar ES_MAX_PREVIEW = 300; // enough to recognise a failure shape; far too short to be a \"raw body\"\nvar ES_REDACTED = '[\u0441\u043a\u0440\u044b\u0442\u043e]';\n\n// Ordered: the most specific secret shapes first, then generic key/value pairs, then PII.\nvar ES_RULES = [\n // Authorization / bearer / api-key headers (with or without a header name)\n { re: /(authorization|proxy-authorization)\\s*[:=]\\s*\\S+/gi, to: '$1: ' + ES_REDACTED },\n { re: /\\bbearer\\s+[A-Za-z0-9._\\-~+/]{8,}=*/gi, to: 'bearer ' + ES_REDACTED },\n { re: /\\bbasic\\s+[A-Za-z0-9+/]{8,}=*/gi, to: 'basic ' + ES_REDACTED },\n // cookies / set-cookie\n { re: /(set-cookie|cookie)\\s*[:=]\\s*[^\\n;]+/gi, to: '$1: ' + ES_REDACTED },\n // provider key formats (Anthropic sk-ant-\u2026, generic sk-\u2026, Google AIza\u2026, GitHub gh[pousr]_\u2026)\n { re: /\\bsk-ant-[A-Za-z0-9._\\-]{8,}/g, to: ES_REDACTED },\n { re: /\\bsk-[A-Za-z0-9._\\-]{16,}/g, to: ES_REDACTED },\n { re: /\\bAIza[0-9A-Za-z._\\-]{20,}/g, to: ES_REDACTED },\n { re: /\\bgh[pousr]_[A-Za-z0-9]{20,}/g, to: ES_REDACTED },\n // JWTs\n { re: /\\beyJ[A-Za-z0-9._\\-]{10,}\\.[A-Za-z0-9._\\-]{10,}\\.[A-Za-z0-9._\\-]{4,}/g, to: ES_REDACTED },\n // generic \"<something>key|token|secret|password\" : \"<value>\" (JSON or header style)\n { re: /(\"?\\b[\\w.\\-]*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|passwd|pwd|credential)\\b\"?)\\s*[:=]\\s*\"?[^\"\\s,}{&]+\"?/gi, to: '$1: ' + ES_REDACTED },\n // url query secrets: ?key=\u2026 &token=\u2026\n { re: /([?&](?:key|token|api_key|apikey|access_token|secret|password)=)[^&\\s]+/gi, to: '$1' + ES_REDACTED },\n // private PII \u2014 we analyze public positioning, never harvest contacts\n { re: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/g, to: '[email]' },\n { re: /\\+?\\d[\\d\\s().-]{9,}\\d/g, to: '[\u0442\u0435\u043b]' }\n];\n\n// Strip a model's hidden reasoning: it is never persisted, in any shape.\nfunction esStripThinking(s) {\n return esStr(s)\n .replace(/<thinking>[\\s\\S]*?<\\/thinking>/gi, ' ')\n .replace(/<thinking>[\\s\\S]*?<\\/antml:thinking>/gi, ' ')\n .replace(/\"type\"\\s*:\\s*\"thinking\"[\\s\\S]*?(?=[,}]\\s*\"type\"|$)/gi, ' ')\n .replace(/\\bthinking\\s*[:=]\\s*\"[^\"]*\"/gi, ' ');\n}\n\n// sanitizeErrorPreview(raw, opts) -> a bounded, secret-free, PII-free excerpt safe to persist.\nfunction sanitizeErrorPreview(raw, opts) {\n opts = opts || {};\n var max = Number(opts.max_chars);\n if (!isFinite(max) || max <= 0) max = ES_MAX_PREVIEW;\n var s = esStripThinking(raw);\n for (var i = 0; i < ES_RULES.length; i++) s = s.replace(ES_RULES[i].re, ES_RULES[i].to);\n s = s.replace(/\\s+/g, ' ').trim();\n if (s.length > max) s = s.slice(0, max - 1) + '\u2026';\n return s;\n}\n\n// Does a string still look like it carries a secret? Used as a fail-closed assertion before persisting.\nfunction esLooksSecret(s) {\n s = esStr(s);\n return /\\b(sk-ant-|sk-[A-Za-z0-9]{16,}|AIza[0-9A-Za-z]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|eyJ[A-Za-z0-9._-]{10,}\\.)/.test(s) ||\n /(authorization|set-cookie)\\s*[:=]\\s*(?!\\[\u0441\u043a\u0440\u044b\u0442\u043e\\])\\S+/i.test(s);\n}\n\n// The ONLY diagnostic fields a failed record may persist. Anything not listed here is dropped by construction.\nfunction sanitizeErrorRecord(rec, opts) {\n rec = rec || {};\n return {\n provider: esStr(rec.provider),\n source_url: esStr(rec.source_url),\n error_category: esStr(rec.error_category || rec.processing_status),\n parse_error: sanitizeErrorPreview(rec.parse_error, { max_chars: (opts && opts.max_chars) || ES_MAX_PREVIEW }),\n raw_response_preview: sanitizeErrorPreview(rec.raw_response_preview, { max_chars: (opts && opts.max_chars) || ES_MAX_PREVIEW }),\n agent_request_id: esStr(rec.agent_request_id),\n source_run_id: esStr(rec.source_run_id),\n run_id: esStr(rec.run_id),\n created_at: esStr(rec.created_at)\n };\n}\n// --- end embedded error_sanitizer ---\n\nconst data = $json;\nconst srcRecord = $('Set Source Record').first().json;\n\nfunction clamp(v) { const n = parseInt(v); return isNaN(n) ? 1 : Math.min(100, Math.max(1, n)); }\nfunction truncate(s, n) { return (s == null ? '' : String(s)).substring(0, n); }\n\n// Normalize free-text service_type into allowed enum values\nfunction normalizeServiceType(raw, hay) {\n const allowed = ['secured_auto_loan','secured_real_estate_loan','pts_loan','refinancing','mortgage_adjacent','generic_lending'];\n const r = (raw || '').toLowerCase().trim();\n if (allowed.includes(r)) return r;\n const s = r + ' ' + (hay || '');\n if (s.includes('\u043f\u0442\u0441') || s.includes('pts')) return 'pts_loan';\n if ((s.includes('\u0430\u0432\u0442\u043e') || s.includes('\u043c\u0430\u0448\u0438\u043d')) && (s.includes('\u0437\u0430\u043b\u043e\u0433') || s.includes('collateral') || s.includes('\u043b\u043e\u043c\u0431\u0430\u0440\u0434'))) return 'secured_auto_loan';\n if (s.includes('\u043d\u0435\u0434\u0432\u0438\u0436') || s.includes('\u043a\u0432\u0430\u0440\u0442\u0438\u0440') || s.includes('\u0434\u043e\u043c') || s.includes('\u0437\u0435\u043c\u043b')) return 'secured_real_estate_loan';\n if (s.includes('\u0440\u0435\u0444\u0438\u043d\u0430\u043d\u0441') || s.includes('refinanc')) return 'refinancing';\n if (s.includes('\u0438\u043f\u043e\u0442\u0435\u043a') || s.includes('mortgage')) return 'mortgage_adjacent';\n if (s.includes('\u0431\u0438\u0437\u043d\u0435\u0441') || s.includes('business')) {\n if (s.includes('\u043d\u0435\u0434\u0432\u0438\u0436') || s.includes('\u043a\u0432\u0430\u0440\u0442\u0438\u0440')) return 'secured_real_estate_loan';\n return 'generic_lending';\n }\n return 'unknown';\n}\n\n// Descriptive company_name fallback for competitors (never invent a brand)\nfunction companyNameFallback(existing, entity, hay) {\n if (existing && String(existing).trim() !== '') return existing;\n if (entity === 'competitor' && (hay.includes('\u043c\u0444\u043e') || hay.includes('\u043c\u0438\u043a\u0440\u043e\u0444\u0438\u043d\u0430\u043d\u0441') || hay.includes('mfo'))) return '\u041c\u0424\u041e / \u0447\u0430\u0441\u0442\u043d\u044b\u0439 \u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440';\n if (hay.includes('\u0447\u0430\u0441\u0442\u043d\u044b\u0439 \u0438\u043d\u0432\u0435\u0441\u0442\u043e\u0440')) return '\u0427\u0430\u0441\u0442\u043d\u044b\u0439 \u0438\u043d\u0432\u0435\u0441\u0442\u043e\u0440';\n if (hay.includes('\u0430\u0432\u0442\u043e\u043b\u043e\u043c\u0431\u0430\u0440\u0434')) return '\u0410\u0432\u0442\u043e\u043b\u043e\u043c\u0431\u0430\u0440\u0434';\n if (hay.includes('\u0431\u0440\u043e\u043a\u0435\u0440')) return '\u0411\u0440\u043e\u043a\u0435\u0440';\n if (entity === 'competitor') return '\u041a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442 \u0431\u0435\u0437 \u0431\u0440\u0435\u043d\u0434\u0430';\n return '';\n}\n\n// recommended_action normalization driven by final route\nfunction normalizeAction(route, entity, action, leadScore) {\n if (route === 'technical_errors') return 'ignore';\n if (route === 'skipped_log') return 'ignore';\n if (route === 'results' && entity === 'lead_signal') return 'contact';\n if (route === 'review_queue') {\n if (entity === 'lead_signal' && action === 'contact' && leadScore >= 70) return 'contact';\n return 'investigate';\n }\n if (route === 'monitor_queue' && entity === 'competitor') return 'monitor';\n if (route === 'content_queue') {\n if (action === 'contact' || action === 'investigate') return action;\n return 'create_content';\n }\n return action;\n}\n\n// Pass-through for confirmed technical_error (from Parse Repaired JSON)\nif (data.processing_status === 'technical_error' && data.route === 'technical_errors') {\n return [{ json: {\n created_at: data.created_at || srcRecord.parsed_at || '',\n source_type: data.source_type || srcRecord.source_type || '',\n platform: data.platform || srcRecord.platform || '',\n source_url: data.source_url || srcRecord.source_url || '',\n parsed_at: data.parsed_at || srcRecord.parsed_at || '',\n published_at: data.published_at || srcRecord.published_at || '',\n freshness_status: data.freshness_status || 'unknown',\n entity_type: data.entity_type || 'irrelevant',\n company_name: data.company_name || '',\n profile_name: data.profile_name || '',\n profile_url: data.profile_url || srcRecord.profile_url || '',\n region: data.region || '',\n service_type: data.service_type || 'unknown',\n offer_text: data.offer_text || '',\n terms: data.terms || '',\n contact_public: data.contact_public || '',\n text_context: data.text_context || srcRecord.text_context || '',\n detected_need: data.detected_need || '',\n competitor_strength: clamp(data.competitor_strength),\n lead_signal_score: clamp(data.lead_signal_score),\n content_idea_score: clamp(data.content_idea_score),\n quality_score: clamp(data.quality_score),\n reason: data.reason || '',\n recommended_action: 'ignore',\n status: data.status || 'analyzed',\n processing_status: 'technical_error',\n parse_method: data.parse_method || 'technical_error',\n parse_error: sanitizeErrorPreview(data.parse_error || ''),\n raw_response_preview: sanitizeErrorPreview(data.raw_response_preview),\n route: 'technical_errors',\n needs_manual_review: true,\n repair_used: data.repair_used || false,\n repair_status: data.repair_status || ''\n }}];\n}\n\n// Normalize schema fields\nconst entity = data.entity_type || 'irrelevant';\nconst status = data.status || 'analyzed';\nconst recAction = data.recommended_action || 'ignore';\nconst leadScore = clamp(data.lead_signal_score);\nconst compScore = clamp(data.competitor_strength);\nconst contentScore = clamp(data.content_idea_score);\nconst qualScore = clamp(data.quality_score);\n\nconst validEntities = ['competitor','lead_signal','market_signal','content_idea','irrelevant'];\nconst validActions = ['monitor','contact','create_content','ignore','investigate'];\nconst safeEntity = validEntities.includes(entity) ? entity : 'irrelevant';\nconst safeAction = validActions.includes(recAction) ? recAction : 'ignore';\nconst safeStatus = (status === 'skipped') ? 'skipped' : 'analyzed';\n\n// Derived text haystacks for normalization and routing keyword checks\nconst hay = ((data.text_context||'') + ' ' + (data.offer_text||'') + ' ' + (data.detected_need||'') + ' ' + (data.reason||'') + ' ' + (data.service_type||'') + ' ' + (srcRecord.text_context||'')).toLowerCase();\nconst companyHay = ((data.text_context||'') + ' ' + (data.reason||'') + ' ' + (data.offer_text||'') + ' ' + (data.source_url||srcRecord.source_url||'') + ' ' + (data.detected_need||'')).toLowerCase();\n\nconst safeServiceType = normalizeServiceType(data.service_type, hay);\nconst safeCompanyName = companyNameFallback(data.company_name, safeEntity, companyHay);\n\n// Determine processing_status\nlet procStatus = 'parsed_success';\nif (safeStatus === 'skipped' || safeEntity === 'irrelevant') procStatus = 'business_skip';\n\n// Weak / potential lead detection (must run before content_queue routing)\nconst srcType = (data.source_type || srcRecord.source_type || '').toLowerCase();\nconst isSocialOrClassified = (srcType === 'social' || srcType === 'classified');\nconst productTerms = ['loan','collateral','pts','auto','real estate','refinanc','\u0437\u0430\u0439\u043c','\u043a\u0440\u0435\u0434\u0438\u0442','\u0437\u0430\u043b\u043e\u0433','\u043f\u0442\u0441','\u0430\u0432\u0442\u043e','\u043c\u0430\u0448\u0438\u043d','\u043d\u0435\u0434\u0432\u0438\u0436','\u043a\u0432\u0430\u0440\u0442\u0438\u0440','\u0437\u0435\u043c\u043b','\u0440\u0435\u0444\u0438\u043d\u0430\u043d\u0441','\u0438\u043f\u043e\u0442\u0435\u043a'];\nconst mentionsProduct = productTerms.some(t => hay.includes(t));\n\nconst weakLead = (\n (safeEntity === 'lead_signal' && leadScore >= 30 && leadScore <= 69) ||\n (safeAction === 'investigate') ||\n (leadScore >= 30 && isSocialOrClassified && mentionsProduct) ||\n (safeEntity === 'content_idea' && leadScore >= 30 && isSocialOrClassified && safeServiceType !== 'unknown')\n);\n\n// Routing priority:\n// 1 technical_errors (handled above) 2 skipped/irrelevant 3 hot lead 4 weak lead\n// 5 competitor 6 pure content idea 7 fallback review_queue\nlet route;\nlet needsReview;\n\nif (safeStatus === 'skipped' || safeEntity === 'irrelevant') {\n route = 'skipped_log'; needsReview = false;\n} else if (safeEntity === 'lead_signal' && leadScore >= 70 && safeAction === 'contact') {\n route = 'results'; needsReview = false;\n} else if (weakLead) {\n route = 'review_queue'; needsReview = true;\n} else if (safeEntity === 'competitor' && compScore >= 45) {\n route = 'monitor_queue'; needsReview = false;\n} else if (safeEntity === 'content_idea' && contentScore >= 50) {\n route = 'content_queue'; needsReview = true;\n} else {\n route = 'review_queue'; needsReview = true;\n}\n\n// Safety: enforce route is exactly one of the six valid sheet tabs\nconst validRoutes = ['results','review_queue','monitor_queue','content_queue','skipped_log','technical_errors'];\nlet parseError = data.parse_error || '';\nif (!validRoutes.includes(route)) {\n route = 'technical_errors';\n procStatus = 'technical_error';\n needsReview = true;\n parseError = (parseError ? parseError + '; ' : '') + 'invalid_route';\n}\n\n// Final recommended_action consistent with the route\nconst finalAction = normalizeAction(route, safeEntity, safeAction, leadScore);\n\n// v0.1 dedup hint: source_url is the first dedup key. Real scraper workflow\n// should check existing source_url in the target tab before append. No dedup\n// column is emitted yet (see DEC-037 / TABLE_SCHEMA notes).\n\nreturn [{ json: {\n created_at: data.created_at || srcRecord.parsed_at || '',\n source_type: data.source_type || srcRecord.source_type || '',\n platform: data.platform || srcRecord.platform || '',\n source_url: data.source_url || srcRecord.source_url || '',\n parsed_at: data.parsed_at || srcRecord.parsed_at || '',\n published_at: data.published_at || srcRecord.published_at || '',\n freshness_status: data.freshness_status || 'unknown',\n entity_type: safeEntity,\n company_name: safeCompanyName,\n profile_name: data.profile_name || '',\n profile_url: data.profile_url || srcRecord.profile_url || '',\n region: data.region || '',\n service_type: safeServiceType,\n offer_text: data.offer_text || '',\n terms: data.terms || '',\n contact_public: data.contact_public || '',\n text_context: data.text_context || '',\n detected_need: data.detected_need || '',\n competitor_strength: compScore,\n lead_signal_score: leadScore,\n content_idea_score: contentScore,\n quality_score: qualScore,\n reason: data.reason || '',\n recommended_action: finalAction,\n status: safeStatus,\n processing_status: procStatus,\n parse_method: data.parse_method || 'primary_json',\n parse_error: sanitizeErrorPreview(parseError),\n raw_response_preview: sanitizeErrorPreview(data.raw_response_preview),\n route: route,\n needs_manual_review: needsReview,\n repair_used: data.repair_used || false,\n repair_status: data.repair_status || ''\n}}];"
}
},
{
"id": "rr000000-0000-0000-0000-000000000099",
"name": "Append to Dynamic Route Sheet",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [
2440,
300
],
"parameters": {
"authentication": "serviceAccount",
"operation": "append",
"documentId": {
"__rl": true,
"value": "PASTE_SPREADSHEET_ID_HERE",
"mode": "id"
},
"sheetName": {
"__rl": true,
"value": "={{ $json.route }}",
"mode": "name"
},
"columns": {
"mappingMode": "autoMapInputData",
"value": {},
"matchingColumns": [],
"schema": []
},
"options": {}
},
"credentials": {
"googleApi": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 5000,
"onError": "continueRegularOutput",
"alwaysOutputData": true
}
],
"connections": {
"Manual Start": {
"main": [
[
{
"node": "Set Source Record",
"type": "main",
"index": 0
}
]
]
},
"Set Source Record": {
"main": [
[
{
"node": "Build Primary Claude Request",
"type": "main",
"index": 0
}
]
]
},
"Build Primary Claude Request": {
"main": [
[
{
"node": "Claude Primary API Request",
"type": "main",
"index": 0
}
]
]
},
"Claude Primary API Request": {
"main": [
[
{
"node": "Parse Primary JSON",
"type": "main",
"index": 0
}
]
]
},
"Parse Primary JSON": {
"main": [
[
{
"node": "IF Primary Parse OK?",
"type": "main",
"index": 0
}
]
]
},
"IF Primary Parse OK?": {
"main": [
[
{
"node": "Normalize + Route",
"type": "main",
"index": 0
}
],
[
{
"node": "Build Repair Request",
"type": "main",
"index": 0
}
]
]
},
"Build Repair Request": {
"main": [
[
{
"node": "Claude Repair API Request",
"type": "main",
"index": 0
}
]
]
},
"Claude Repair API Request": {
"main": [
[
{
"node": "Parse Repaired JSON",
"type": "main",
"index": 0
}
]
]
},
"Parse Repaired JSON": {
"main": [
[
{
"node": "Normalize + Route",
"type": "main",
"index": 0
}
]
]
},
"Normalize + Route": {
"main": [
[
{
"node": "Append to Dynamic Route Sheet",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "rr000000-resilient-router-v005-production-patched-20260606"
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleApihttpHeaderAuth
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
02 - Claude API Single Record Analysis v2 RESILIENT ROUTER PRODUCTION. Uses httpRequest, googleSheets. Event-driven trigger; 12 nodes.
Source: https://github.com/CodeVinci8/vinci-ai-pilot/blob/main/n8n/workflows/02_claude_api_single_record_v2_resilient_router_production.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
TrackCollect_deeper. Uses googleSheets, httpRequest, @n-octo-n/n8n-nodes-json-database, itemLists. Event-driven trigger; 80 nodes.
This template is ideal for solo store owners, eCommerce marketers, automation beginners, or anyone using Shopify and Gmail who wants to recover lost revenue without coding.
PCN. Uses googleSheets, httpRequest, @n-octo-n/n8n-nodes-json-database, itemLists. Event-driven trigger; 60 nodes.
Product Ad Machine. Uses googleDrive, googleSheets, httpRequest, @ffmpeg-micro/n8n-nodes-ffmpeg-micro. Event-driven trigger; 60 nodes.
Basic AI SEO KW Research n8n DataForSEO Gumroad 030125. Uses googleSheets, googleDrive, httpRequest. Event-driven trigger; 56 nodes.