AutomationFlowsData & Sheets › Wf-21 Triage_one

Wf-21 Triage_one

WF-21 triage_one. Uses executeWorkflowTrigger, httpRequest, postgres. Event-driven trigger; 10 nodes.

Event trigger★★★★☆ complexity10 nodesExecute Workflow TriggerHTTP RequestPostgres
Data & Sheets Trigger: Event Nodes: 10 Complexity: ★★★★☆ Added:

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 →

Download .json
{
  "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.

Pro

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 →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

Reagendamiento_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 89 nodes.

Execute Workflow Trigger, Redis, HTTP Request +3
Data & Sheets

Agendamiento_v2. Uses n8n-nodes-evolution-api, redis, httpRequest, executeWorkflowTrigger. Event-driven trigger; 59 nodes.

N8N Nodes Evolution Api, Redis, HTTP Request +3
Data & Sheets

Cancelacion_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 46 nodes.

Execute Workflow Trigger, Redis, HTTP Request +3
Data & Sheets

Save_Extraction. Uses executeWorkflowTrigger, postgres, httpRequest. Event-driven trigger; 22 nodes.

Execute Workflow Trigger, Postgres, HTTP Request
Data & Sheets

Youtube Searcher. Uses splitInBatches, httpRequest, manualTrigger, executeWorkflowTrigger. Event-driven trigger; 21 nodes.

HTTP Request, Execute Workflow Trigger, Postgres +1