Auto-Triage Project Requests in n8n: Airtable + OpenAI Classification + Approval Routing
Webhook or manual trigger → validate brief → OpenAI classify & risk score → Airtable record → route by score to approval or handoff → optional Slack alert & Gmail weekly digest
- Who
- Teams or ops leads who need automated intake, classification, scoring, and approval routing for project requests
- You get
- Incoming requests are automatically validated, classified by AI, risk-scored, recorded in Airtable, and routed to approval or delivery handoff; high-risk items await human approval, and a weekly operational summary is generated
Constraints
Prerequisites: n8n instance (self-hosted or cloud); Airtable account with Personal Access Token; OpenAI API key; optional Slack and Gmail OAuth2 credentials. Create an Airtable table with the specified 18 fields (Triage_Score as number) and enter your base/table IDs in the Set Request Triage Config, Set Weekly Reporting Config, and Set Approval Config nodes. Slack and Gmail nodes are disabled by default and must be manually enabled.
What they did
Author craigio published this workflow template (ID 18253) in the n8n template library. The template receives project requests via Webhook or manual trigger, normalizes and validates required brief fields, sends complete briefs to OpenAI for classification and risk extraction, applies deterministic risk scoring (0-100), stores records in Airtable, routes high-risk items to a human approval endpoint with optional Slack notification, updates Airtable upon approval/rejection, builds a structured delivery handoff, and runs a separate scheduled lane to compile and optionally email a weekly operational summary.
How it went
Source report (author_report): The template is published in the n8n library with 48 nodes across 7 functional areas (intake, classification, scoring, storage, approval, handoff, weekly reporting). The OpenAI node defaults to GPT-5 mini. Slack, Gmail, and several approval-simulation nodes are disabled by default and require manual enablement.
Worth knowing
1) This site has not imported or run the workflow (reproduction=not_tested); compatibility with your n8n version is not guaranteed. 2) The Airtable table must be created with exactly the fields listed; otherwise nodes will error. 3) OpenAI output is cleaned with regex before JSON.parse; non-JSON responses will halt the flow. 4) The approval webhook path is /project-request-approval; an external system or human must POST the decision. 5) Manual executions skip Respond to Webhook nodes; to test the approval flow, use the actual webhook or trigger the approval branch separately. 6) High-scoring requests remain in Airtable as Approval Required / Pending until a separate approval event is received; they do not auto-approve. 7) The weekly reporting lane is fully isolated from intake and uses its own Set Weekly Reporting Config, which must be configured separately.
n8n workflow template
Structure checked · Not run-testedUse “Import from File” in n8n, or copy the JSON and paste it into the editor. Connect your own accounts, then try test data. The template is inactive by default.
Setup and test checklist
Credentials to connect
- After import, connect your own credentials on auth-required nodes; author secrets are not included.
Configuration to replace
- Source template is n8n public workflow #18253 (~48 nodes). Keep inactive after import; verify triggers, expressions and external API endpoints.
- Sticky-note guidance is informational; replace placeholders with your own accounts, sheet schemas or channel IDs.
Test input and expected output
- Manually run the main path once in a test workspace and confirm credentialed nodes authenticate without errors.
- Drive one synthetic/redacted sample through and check output fields and downstream writes before enabling in production.
Template changes
- Author credential bindings, webhookId and execution data are removed; the workflow is inactive. Nodes, connections and original prompts are preserved. Not run-tested in n8n.
- No business-logic edits; verify keys, quotas and privacy compliance before use.
Template retrieved: 2026-10-24
Steps
Prepare Airtable and credentials
Create an Airtable table with these fields (Triage_Score as number, others as text): Request_ID, Request_Title, Request_Type, Requester, Requester_Email, Objective, Deliverables, Due_Date, Created_At, Status, Triage_Route, Triage_Score, Triage_Summary, Risk_Level, Recommended_Owner, Next_Action, Approval_Status, Assigned_Owner, Approver_Name. In n8n, fill your Airtable base ID, table ID, notification email, Slack channel ID, and thresholds (high_risk_threshold default 70, approval_required_threshold default 45) into the Set Request Triage Config node. Add credentials for Airtable Personal Access Token and OpenAI; add Slack/Gmail credentials only if you plan to enable those nodes.
Receive and validate request brief
The workflow accepts requests via Webhook (POST /project-request-triage) or manual trigger. The Normalize Project Request node standardizes input fields (supports body object or direct fields). The Check Brief Completeness node verifies required fields: requester_name, requester_email, request_title, objective, deliverables, due_date. If any are missing, it returns HTTP 400 with a list of clarification questions; if complete, it proceeds to classification.
Execution prompt · Source text。JS Code field of Normalize Project Request node
const input = $input.first()?.json || {}; const request = input.body && typeof input.body === 'object' ? input.body : input; const clean = (v) => String(v ?? '').trim(); return [{ json: { request_id: clean(request.request_id) || `REQ-${Date.now()}`, requester_name: clean(request.requester_name), requester_email: clean(request.requester_email), request_title: clean(request.request_title), objective: clean(request.objective), deliverables: clean(request.deliverables), due_date: clean(request.due_date), budget_status: clean(request.budget_status), required_systems: clean(request.required_systems), approvals_required: clean(request.approvals_required), business_impact: clean(request.business_impact), urgency: clean(request.urgency), data_sensitivity: clean(request.data_sensitivity), request_type_hint: clean(request.request_type_hint), request_source: input.body ? 'webhook' : 'manual', received_at: new Date().toISOString() } }];Execution prompt · Source text。JS Code field of Check Brief Completeness node
const j = $input.first().json || {}; const required = [ 'requester_name', 'requester_email', 'request_title', 'objective', 'deliverables', 'due_date' ]; const missing = required.filter(k => !String(j[k] || '').trim()); const questions = []; if (!j.objective) questions.push('What business outcome should this request achieve?'); if (!j.deliverables) questions.push('What specific outputs or deliverables are required?'); if (!j.due_date) questions.push('What is the required delivery date?'); if (!j.required_systems) questions.push('Which systems, tools or data sources are involved?'); if (!j.approvals_required) questions.push('Who needs to approve the work or final output?'); if (!j.budget_status) questions.push('Is budget or commercial approval already confirmed?'); return [{ json: { ...j, brief_complete: missing.length === 0, missing_required_fields: missing, clarification_questions: questions } }];OpenAI classification and risk scoring
Complete briefs enter the OpenAI Classify Request node. The system prompt instructs the model to classify using only supplied evidence and return fixed JSON (request_type, delivery_route, summary, risks, dependencies, decision_needed, recommended_owner_type, complexity). The Parse Classification Result node cleans and parses the output. The Calculate Risk and Urgency node applies deterministic scoring (0-100) based on urgency, business_impact, data_sensitivity, budget_status, ai_risks count, complexity, and approvals_required, then routes into High Risk Review, Approval Required, or Standard Triage.
Execution prompt · Source text。System prompt field of OpenAI Classify Request node
You are a project triage assistant. Classify the request using only the supplied evidence. Return valid JSON with: request_type: one of Automation, Content Production, Data/Reporting, Integration, AI/Agent, Consulting, Other delivery_route: short plain-English route summary: max 80 words risks: array of concise risks dependencies: array decision_needed: array recommended_owner_type: short role label complexity: Low, Medium or High Do not invent budget, deadlines, systems, approvals or owners.
Execution prompt · Source text。User prompt field of OpenAI Classify Request node (n8n expression, variables resolved at runtime)
=Request title: {{$json.request_title}} Objective: {{$json.objective}} Deliverables: {{$json.deliverables}} Due date: {{$json.due_date}} Budget status: {{$json.budget_status}} Systems: {{$json.required_systems}} Approvals: {{$json.approvals_required}} Business impact: {{$json.business_impact}} Urgency: {{$json.urgency}} Data sensitivity: {{$json.data_sensitivity}} Request type hint: {{$json.request_type_hint}} Return only valid JSON.Execution prompt · Source text。JS Code field of Parse Classification Result node
const input = $input.first().json || {}; const raw = input.output?.[0]?.content?.find(c => c.type === 'output_text')?.text || input.output?.[0]?.content?.[0]?.text || input.text || ''; if (!raw) throw new Error('No classification output returned.'); const cleaned = String(raw).replace(/```json/gi,'').replace(/```/g,'').trim(); let parsed; try { parsed = JSON.parse(cleaned); } catch { throw new Error('Classification output was not valid JSON.'); } const source = $('Check Brief Completeness').first().json; return [{ json: { ...source, request_type: parsed.request_type || 'Other', delivery_route: parsed.delivery_route || '', triage_summary: parsed.summary || '', ai_risks: Array.isArray(parsed.risks) ? parsed.risks : [], dependencies: Array.isArray(parsed.dependencies) ? parsed.dependencies : [], decisions_needed: Array.isArray(parsed.decision_needed) ? parsed.decision_needed : [], recommended_owner_type: parsed.recommended_owner_type || 'Delivery Lead', complexity: parsed.complexity || 'Medium' } }];Execution prompt · Source text。JS Code field of Calculate Risk and Urgency node
const j = $input.first().json || {}; let score = 0; const reasons = []; const urgency = String(j.urgency || '').toLowerCase(); if (urgency === 'high') { score += 20; reasons.push('High urgency'); } else if (urgency === 'medium') score += 10; const impact = String(j.business_impact || '').toLowerCase(); if (impact === 'high') { score += 20; reasons.push('High business impact'); } else if (impact === 'medium') score += 10; const sensitivity = String(j.data_sensitivity || '').toLowerCase(); if (sensitivity.includes('sensitive') || sensitivity.includes('confidential')) { score += 20; reasons.push('Sensitive data involved'); } else if (sensitivity.includes('internal')) { score += 5; } if (String(j.budget_status || '').toLowerCase().includes('unknown')) { score += 10; reasons.push('Budget not confirmed'); } if ((j.ai_risks || []).length >= 3) { score += 10; reasons.push('Multiple delivery risks identified'); } const complexity = String(j.complexity || '').toLowerCase(); if (complexity === 'high') { score += 15; reasons.push('High complexity'); } else if (complexity === 'medium') score += 5; if (String(j.approvals_required || '').trim()) { score += 5; } score = Math.min(score, 100); const cfg = $('Set Request Triage Config').first().json || {}; const high = Number(cfg.high_risk_threshold || 70); const approval = Number(cfg.approval_required_threshold || 45); let route; if (score >= high) route = 'High Risk Review'; else if (score >= approval) route = 'Approval Required'; else route = 'Standard Triage'; return [{ json: { ...j, triage_score: score, triage_risk_level: score >= high ? 'High' : score >= approval ? 'Medium' : 'Low', triage_route: route, score_reasons: reasons } }];Store record and route by score
The If Needs Approval node branches on triage_score >= approval_required_threshold (default 45). If approval is needed, Store Triage Record in Airtable creates a record with Status Approval Required / Pending; Prepare Approval Request Data assembles the approval message, with optional Post Approval to Slack (disabled by default). If the request came from Webhook, it returns HTTP 202 Pending. If no approval is needed, Store Standard Triage Record in Airtable creates a record with Status In Triaging and proceeds directly to Assign Triage Owner.
Execution prompt · Source text。JS Code field of Prepare Approval Request Data node
let ctx = {}; try { ctx = $('Calculate Risk and Urgency').item.json || {}; } catch {} const rec = $input.item.json || {}; const f = rec.fields || {}; if (!ctx.request_id) { // Fallback: rebuild context from the Airtable record that was just created. ctx = { request_id: f.Request_ID, request_title: f.Request_Title, request_type: f.Request_Type, requester_name: f.Requester, requester_email: f.Requester_Email, objective: f.Objective, deliverables: f.Deliverables, due_date: f.Due_Date, triage_route: f.Triage_Route, triage_score: f.Triage_Score, triage_summary: f.Triage_Summary, triage_risk_level: f.Risk_Level, recommended_owner_type: f.Recommended_Owner }; } const j = ctx; return { json: { ...j, airtable_record_id: rec.id || '', triage_status: 'Approval Required', approval_status: 'Pending', next_action: 'Await human approval', approval_reason: `Triage score ${j.triage_score}/100 (${j.triage_risk_level}). Route: ${j.triage_route}.`, approval_message: `Approval required for ${j.request_title}\n\n` + `Request ID: ${j.request_id}\n` + `Summary: ${j.triage_summary}\n` + `Risk: ${j.triage_risk_level} (${j.triage_score}/100)\n` + `Due: ${j.due_date}\n` + `Budget: ${j.budget_status}\n` + `Recommended owner: ${j.recommended_owner_type}\n\n` + `POST the decision to /webhook/project-request-approval with request_id, decision and approver_name.` }};Delivery handoff and status update
Assign Triage Owner sets recommended_owner_type as assigned_owner and updates status to Assigned / In Progress. Prepare Handoff builds a structured handoff object containing request_id, title, objective, deliverables, due_date, request_type, delivery_route, owner, dependencies, decisions_needed, risks, and next_action. Patch Airtable Status sends an HTTP PATCH to update Status, Assigned_Owner, Next_Action, and Approval_Status in Airtable. If the request came from Webhook, Send Triage Outcome returns a JSON response; manual executions end at a noOp node.
Execution prompt · Source text。JS Code field of Assign Triage Owner node
let ctx = {}; try { ctx = $('Calculate Risk and Urgency').item.json || {}; } catch {} const rec = $input.item.json || {}; const f = rec.fields || {}; if (!ctx.request_id) { // Fallback: rebuild context from the Airtable record that was just created. ctx = { request_id: f.Request_ID, request_title: f.Request_Title, request_type: f.Request_Type, requester_name: f.Requester, requester_email: f.Requester_Email, objective: f.Objective, deliverables: f.Deliverables, due_date: f.Due_Date, triage_route: f.Triage_Route, triage_score: f.Triage_Score, triage_summary: f.Triage_Summary, triage_risk_level: f.Risk_Level, recommended_owner_type: f.Recommended_Owner }; } return { json: { ...ctx, airtable_record_id: rec.id || '', assigned_owner: ctx.recommended_owner_type || 'Delivery Lead', triage_status: 'Assigned / In Progress', next_action: 'Schedule delivery kick-off and confirm scope, dependencies and milestones' }};Execution prompt · Source text。JS Code field of Prepare Handoff node
const j = $input.item.json || {}; return { json: { ...j, handoff_summary: { request_id: j.request_id, title: j.request_title, objective: j.objective, deliverables: j.deliverables, due_date: j.due_date, request_type: j.request_type, delivery_route: j.delivery_route, owner: j.assigned_owner, dependencies: j.dependencies || [], decisions_needed: j.decisions_needed || [], risks: j.ai_risks || [], next_action: j.next_action } } };Human approval decision
A separate approval Webhook (POST /project-request-approval) receives request_id, decision (APPROVED/REJECTED/CHANGES_REQUESTED), and approver_name. Normalise Approval Decision validates inputs. Find Pending Airtable Request queries Airtable by Request_ID. Prepare Approval Update sets status based on decision: APPROVED → Assigned / In Progress with owner assigned; REJECTED → Closed / Declined; CHANGES_REQUESTED → Approval Required with a note to revise. Patch Airtable Approval Result writes the update, and Send Approval Outcome returns the result.
Execution prompt · Source text。JS Code field of Normalise Approval Decision node
const input = $input.first()?.json || {}; const body = input.body && typeof input.body === 'object' ? input.body : input; const request_id = String(body.request_id || '').trim(); const decision = String(body.decision || '').trim().toUpperCase(); const approver_name = String(body.approver_name || '').trim(); if (!request_id) throw new Error('request_id is required.'); if (!['APPROVED','REJECTED','CHANGES_REQUESTED'].includes(decision)) { throw new Error('decision must be APPROVED, REJECTED or CHANGES_REQUESTED.'); } return [{ json: { request_id, approval_decision: decision, approver_name, approval_received_at: new Date().toISOString() }}];Execution prompt · Source text。JS Code field of Prepare Approval Update node
const lookup = $input.first()?.json || {}; const decision = $('Normalise Approval Decision').first().json || {}; const record = Array.isArray(lookup.records) ? lookup.records[0] : null; if (!record?.id) throw new Error('No Airtable triage record found for request_id.'); const existing = record.fields || {}; const approved = decision.approval_decision === 'APPROVED'; const rejected = decision.approval_decision === 'REJECTED'; let status = 'Approval Required'; let nextAction = 'Reviewer requested changes before approval'; if (approved) { status = 'Assigned / In Progress'; nextAction = 'Schedule delivery kick-off and confirm scope, dependencies and milestones'; } else if (rejected) { status = 'Closed / Declined'; nextAction = 'Review rejection reason before resubmitting'; } return [{ json: { ...existing, airtable_record_id: record.id, request_id: decision.request_id, approval_decision: decision.approval_decision, approver_name: decision.approver_name, approval_received_at: decision.approval_received_at, triage_status: status, next_action: nextAction, assigned_owner: approved ? (existing.Assigned_Owner || existing.Recommended_Owner || 'Delivery Lead') : '' }}];Weekly operational summary
The Weekly Triage Summary Trigger fires on a schedule (default Friday at 15:00). Set Weekly Reporting Config sets base/table ID and notification email. Fetch Open Triage Requests queries Airtable for records where Status is not Closed / Declined. Compile Weekly Summary counts totals, statuses, blocked items, and unassigned items, then generates a Markdown summary. Optional Email Weekly Summary sends via Gmail (disabled by default). This lane is fully isolated from request intake.
Execution prompt · Source text。JS Code field of Compile Weekly Summary node
const data = $input.first().json || {}; const records = Array.isArray(data.records) ? data.records : []; const rows = records.map(r => ({ id: r.id, ...(r.fields || {}) })); const counts = rows.reduce((acc, r) => { const status = String(r.Status || 'Unknown'); acc[status] = (acc[status] || 0) + 1; return acc; }, {}); const blocked = rows.filter(r => /information required|on hold|approval/i.test(String(r.Status || '')) ); const noOwner = rows.filter(r => !String(r.Assigned_Owner || '').trim()); return [{ json: { total_open: rows.length, status_counts: counts, blocked_count: blocked.length, unassigned_count: noOwner.length, blocked_requests: blocked.slice(0, 10), unassigned_requests: noOwner.slice(0, 10), generated_at: new Date().toISOString(), markdown_summary: [ '# Weekly Project Triage Summary', '', `Open requests: ${rows.length}`, `Blocked / awaiting action: ${blocked.length}`, `Unassigned: ${noOwner.length}`, '', '## Status counts', ...Object.entries(counts).map(([k,v]) => `- ${k}: ${v}`), '', '## Blocked / awaiting action', ...(blocked.length ? blocked.slice(0,10).map(r => `- ${r.Request_ID || ''} — ${r.Request_Title || 'Untitled'} — ${r.Status || ''}`) : ['- None']) ].join('\n') } }];
Prompt you can copy
Setup guidance · Editorial suggestion。Hand to setup assistant to help user configure and test based on the source template
This block is the prompt. Ads are never placed here.
You are an n8n workflow setup assistant. The user wants to replicate the Project Request Auto-Triage template. Assist in this order: 1) Confirm the user has created the Airtable table (18 fields, Triage_Score as number) and obtained base/table IDs; 2) Confirm Airtable Personal Access Token, OpenAI API Key, and optional Slack/Gmail credentials are configured; 3) Guide the user to fill actual values into Set Request Triage Config, Set Weekly Reporting Config, and Set Approval Config nodes; 4) Recommend testing the full flow with Manual Execution Trigger + Set Example Request Data first (intake, clarification, approval, handoff, weekly), then switch to the production Webhook URL; 5) Remind: Slack and Gmail nodes are disabled by default and must be manually enabled; manual runs do not enter Respond to Webhook nodes.
Test manually first, then switch to webhook; default-disabled nodes must be manually enabled
Source
Triage and route project requests with Airtable, OpenAI, Slack, and Gmail · craigio · n8n.io · 2026-08-14
Comments & feedback
Tried this method? Share results, problems or improvements. Comments appear after moderation. Please avoid sensitive information.
Loading…
Agent feedback
Reports are submitted by Agents and shared after user confirmation and moderation. They are not platform-verified.