This workflow follows the Execute Workflow Trigger → 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": "WF-21 triage_one",
"nodes": [
{
"id": "trigger",
"name": "WF Input",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
0,
0
],
"parameters": {
"workflowInputs": {
"values": [
{
"name": "triage_body",
"type": "string"
},
{
"name": "raw_doc_id",
"type": "string"
},
{
"name": "canonical_url",
"type": "string"
},
{
"name": "title",
"type": "string"
},
{
"name": "published_at",
"type": "string"
},
{
"name": "run_id",
"type": "string"
}
]
}
}
},
{
"id": "guard",
"name": "Guard single item",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
100,
0
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "const n = $input.all().length;\nif (n !== 1) throw new Error('WF-21 expects exactly 1 item per execution, received ' + n +\n ' \u2014 WF-20 \"Run triage\" must have mode: \"each\"');\nreturn $input.all();"
}
},
{
"id": "triage",
"name": "Triage",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
200,
0
],
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:1234/v1/chat/completions",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.triage_body }}",
"options": {
"timeout": 120000
}
},
"credentials": {
"httpBearerAuth": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 1000,
"onError": "continueRegularOutput"
},
{
"id": "validate1",
"name": "Validate triage",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
400,
0
],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Validation of the triage model's reply. Inlined into WF-21 by\n// scripts/build-wf20.js; covered by tests/triage-validate.test.js.\n\n// Qwen puts chain-of-thought in reasoning_content. When it thinks past the\n// token budget, `content` comes back empty with the JSON stranded in there \u2014\n// recover it rather than burning a retry.\nfunction parseContent(j) {\n try {\n const msg = (j && j.choices && j.choices[0] && j.choices[0].message) || {};\n let c = msg.content;\n if (!c && msg.reasoning_content) {\n const m = String(msg.reasoning_content).match(/\\{[\\s\\S]*\\}/);\n if (m) c = m[0];\n }\n if (!c) return null;\n c = String(c).trim().replace(/^```(json)?/i, '').replace(/```$/, '').trim();\n const o = JSON.parse(c);\n const ok = (v) => Number.isInteger(v) && v >= 1 && v <= 5;\n if (o && typeof o.summary === 'string' && ok(o.relevance) &&\n ok(o.specificity) && ok(o.angle_strength) &&\n Array.isArray(o.tags) && o.tags.length >= 1) return o;\n return null;\n } catch (e) { return null; }\n}\n\n// Postgres array literal. Tags arrive from a model, so treat them as hostile:\n// braces, quotes, commas and backslashes would all corrupt the literal.\nfunction tagsPg(tags) {\n const clean = (tags || []).slice(0, 5)\n .map(t => String(t).toLowerCase().replace(/[{}\",\\\\]/g, '').trim())\n .filter(Boolean);\n return '{' + clean.join(',') + '}';\n}\n\n// The six fields every triage returns, skill or no skill.\nconst BASE_FIELDS = ['summary', 'angle', 'relevance', 'specificity', 'angle_strength', 'tags'];\n\n// A skill widens the response schema, so the reply carries extra properties.\n// Split them off into their own object: the fixed columns stay columns, the\n// skill's fields go to feed_items.structured as JSON.\nfunction splitStructured(o) {\n if (!o || typeof o !== 'object') return { structured: null };\n const structured = {};\n let any = false;\n for (const k of Object.keys(o)) {\n if (BASE_FIELDS.includes(k)) continue;\n structured[k] = o[k];\n any = true;\n }\n return { structured: any ? structured : null };\n}\n\n// Merge a skill's extra properties into the base response schema. strict mode\n// requires every declared property to be listed as required, so add both.\nfunction withSkill(baseSchema, extraProps) {\n const s = JSON.parse(JSON.stringify(baseSchema));\n if (!extraProps || typeof extraProps !== 'object') return s;\n const target = s.json_schema.schema;\n for (const [k, v] of Object.entries(extraProps)) {\n if (BASE_FIELDS.includes(k)) continue; // a skill may not redefine the core\n target.properties[k] = v;\n if (!target.required.includes(k)) target.required.push(k);\n }\n return s;\n}\n\nconst doc = $('WF Input').first().json;\nconst rawDocId = doc.raw_doc_id;\nconst o = parseContent($json);\nif (!o) return { json: { __invalid: true } };\nreturn { json: { __invalid: false, raw_doc_id: rawDocId, canonical_url: doc.canonical_url, title: doc.title,\n summary: o.summary, angle: o.angle || '', relevance: o.relevance,\n specificity: o.specificity, angle_strength: o.angle_strength, tags_pg: tagsPg(o.tags),\n structured_json: (splitStructured(o).structured ? JSON.stringify(splitStructured(o).structured) : ''),\n published_at: doc.published_at || '', run_id: doc.run_id } };\n"
}
},
{
"id": "triagevalid",
"name": "Triage valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
600,
0
],
"parameters": {
"options": {},
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"combinator": "and",
"conditions": [
{
"id": "c1",
"leftValue": "={{ Boolean($json.__invalid) }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
}
}
},
{
"id": "buildretry",
"name": "Build retry",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
120
],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const doc = $('WF Input').first().json;\nconst base = JSON.parse(doc.triage_body);\nlet prev = 'no output';\ntry { prev = $('Triage').first().json.choices[0].message.content || 'no output'; } catch (e) {}\nbase.messages.push({ role: 'assistant', content: String(prev).slice(0, 2000) });\nbase.messages.push({ role: 'user', content: 'Your previous output was not valid JSON matching the required schema. Return ONLY the JSON object with fields summary, angle, relevance, tags.' });\nreturn { json: { retry_body: JSON.stringify(base) } };"
}
},
{
"id": "triage2",
"name": "Triage retry",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
850,
120
],
"parameters": {
"method": "POST",
"url": "http://host.docker.internal:1234/v1/chat/completions",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json.retry_body }}",
"options": {
"timeout": 120000
}
},
"credentials": {
"httpBearerAuth": {
"name": "<your credential>"
}
},
"retryOnFail": false,
"onError": "continueRegularOutput"
},
{
"id": "validate2",
"name": "Validate retry",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1000,
120
],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Validation of the triage model's reply. Inlined into WF-21 by\n// scripts/build-wf20.js; covered by tests/triage-validate.test.js.\n\n// Qwen puts chain-of-thought in reasoning_content. When it thinks past the\n// token budget, `content` comes back empty with the JSON stranded in there \u2014\n// recover it rather than burning a retry.\nfunction parseContent(j) {\n try {\n const msg = (j && j.choices && j.choices[0] && j.choices[0].message) || {};\n let c = msg.content;\n if (!c && msg.reasoning_content) {\n const m = String(msg.reasoning_content).match(/\\{[\\s\\S]*\\}/);\n if (m) c = m[0];\n }\n if (!c) return null;\n c = String(c).trim().replace(/^```(json)?/i, '').replace(/```$/, '').trim();\n const o = JSON.parse(c);\n const ok = (v) => Number.isInteger(v) && v >= 1 && v <= 5;\n if (o && typeof o.summary === 'string' && ok(o.relevance) &&\n ok(o.specificity) && ok(o.angle_strength) &&\n Array.isArray(o.tags) && o.tags.length >= 1) return o;\n return null;\n } catch (e) { return null; }\n}\n\n// Postgres array literal. Tags arrive from a model, so treat them as hostile:\n// braces, quotes, commas and backslashes would all corrupt the literal.\nfunction tagsPg(tags) {\n const clean = (tags || []).slice(0, 5)\n .map(t => String(t).toLowerCase().replace(/[{}\",\\\\]/g, '').trim())\n .filter(Boolean);\n return '{' + clean.join(',') + '}';\n}\n\n// The six fields every triage returns, skill or no skill.\nconst BASE_FIELDS = ['summary', 'angle', 'relevance', 'specificity', 'angle_strength', 'tags'];\n\n// A skill widens the response schema, so the reply carries extra properties.\n// Split them off into their own object: the fixed columns stay columns, the\n// skill's fields go to feed_items.structured as JSON.\nfunction splitStructured(o) {\n if (!o || typeof o !== 'object') return { structured: null };\n const structured = {};\n let any = false;\n for (const k of Object.keys(o)) {\n if (BASE_FIELDS.includes(k)) continue;\n structured[k] = o[k];\n any = true;\n }\n return { structured: any ? structured : null };\n}\n\n// Merge a skill's extra properties into the base response schema. strict mode\n// requires every declared property to be listed as required, so add both.\nfunction withSkill(baseSchema, extraProps) {\n const s = JSON.parse(JSON.stringify(baseSchema));\n if (!extraProps || typeof extraProps !== 'object') return s;\n const target = s.json_schema.schema;\n for (const [k, v] of Object.entries(extraProps)) {\n if (BASE_FIELDS.includes(k)) continue; // a skill may not redefine the core\n target.properties[k] = v;\n if (!target.required.includes(k)) target.required.push(k);\n }\n return s;\n}\n\nconst doc = $('WF Input').first().json;\nconst rawDocId = doc.raw_doc_id;\nconst o = parseContent($json);\nif (!o) return { json: { raw_doc_id: rawDocId, canonical_url: doc.canonical_url, title: doc.title,\n summary: 'TRIAGE_FAILED', angle: '', relevance: '', specificity: '', angle_strength: '', tags_pg: '{}',\n structured_json: '', published_at: doc.published_at || '', run_id: doc.run_id } };\nreturn { json: { raw_doc_id: rawDocId, canonical_url: doc.canonical_url, title: doc.title,\n summary: o.summary, angle: o.angle || '', relevance: o.relevance,\n specificity: o.specificity, angle_strength: o.angle_strength, tags_pg: tagsPg(o.tags),\n structured_json: (splitStructured(o).structured ? JSON.stringify(splitStructured(o).structured) : ''),\n published_at: doc.published_at || '', run_id: doc.run_id } };\n"
}
},
{
"id": "mergetriage",
"name": "Merge triaged",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1200,
0
],
"parameters": {
"mode": "append",
"numberInputs": 2
}
},
{
"id": "insertfeed",
"name": "Insert feed",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [
1400,
0
],
"parameters": {
"operation": "executeQuery",
"query": "with del as (delete from feed_items where raw_doc_id = $1::bigint) insert into feed_items (raw_doc_id, canonical_url, title, summary, angle, relevance, tags, run_id, published_at, specificity, angle_strength, structured) values ($1::bigint, $2, nullif($3,''), $4, nullif($5,''), nullif($6::text,'')::int, $7::text[], $8::bigint, nullif($9,'')::timestamptz, nullif($10::text,'')::int, nullif($11::text,'')::int, nullif($12,'')::jsonb) returning id as feed_item_id",
"options": {
"queryReplacement": "={{ $json.raw_doc_id }},{{ $json.canonical_url }},{{ $json.title }},{{ $json.summary }},{{ $json.angle }},{{ $json.relevance }},{{ $json.tags_pg }},{{ $json.run_id }},{{ $json.published_at }},{{ $json.specificity }},{{ $json.angle_strength }},{{ $json.structured_json }}"
}
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
}
}
],
"connections": {
"WF Input": {
"main": [
[
{
"node": "Guard single item",
"type": "main",
"index": 0
}
]
]
},
"Guard single item": {
"main": [
[
{
"node": "Triage",
"type": "main",
"index": 0
}
]
]
},
"Triage": {
"main": [
[
{
"node": "Validate triage",
"type": "main",
"index": 0
}
]
]
},
"Validate triage": {
"main": [
[
{
"node": "Triage valid?",
"type": "main",
"index": 0
}
]
]
},
"Triage valid?": {
"main": [
[
{
"node": "Merge triaged",
"type": "main",
"index": 0
}
],
[
{
"node": "Build retry",
"type": "main",
"index": 0
}
]
]
},
"Build retry": {
"main": [
[
{
"node": "Triage retry",
"type": "main",
"index": 0
}
]
]
},
"Triage retry": {
"main": [
[
{
"node": "Validate retry",
"type": "main",
"index": 0
}
]
]
},
"Validate retry": {
"main": [
[
{
"node": "Merge triaged",
"type": "main",
"index": 1
}
]
]
},
"Merge triaged": {
"main": [
[
{
"node": "Insert feed",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"errorWorkflow": "PNJMA4NbQGmp1xKv"
}
}
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.
httpBearerAuthpostgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
WF-21 triage_one. Uses executeWorkflowTrigger, httpRequest, postgres. Event-driven trigger; 10 nodes.
Source: https://github.com/killerwaz/research-overseer/blob/master/workflows/wf21-triage-one.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.
Reagendamiento_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 89 nodes.
Agendamiento_v2. Uses n8n-nodes-evolution-api, redis, httpRequest, executeWorkflowTrigger. Event-driven trigger; 59 nodes.
Cancelacion_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 46 nodes.
Save_Extraction. Uses executeWorkflowTrigger, postgres, httpRequest. Event-driven trigger; 22 nodes.
Youtube Searcher. Uses splitInBatches, httpRequest, manualTrigger, executeWorkflowTrigger. Event-driven trigger; 21 nodes.