This workflow corresponds to n8n.io template #13870 — we link there as the canonical source.
This workflow follows the Airtable → OpenAI 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 →
{
"id": "tnztcvK5fVFSz3X4M56OF",
"name": "Automate proposals on Upwork with AI, Airtable and Slack",
"tags": [],
"nodes": [
{
"id": "468b4807-7f16-4699-be7d-9cfe32749dee",
"name": "RSS Feed - n8n & Automation",
"type": "n8n-nodes-base.rssFeedReadTrigger",
"position": [
-16,
160
],
"parameters": {
"feedUrl": "YOUR_VOLLNA_RSS_FEED_URL",
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
}
},
"typeVersion": 1
},
{
"id": "abd092d6-1bb3-474a-96a8-a37ca89541f9",
"name": "Filter: Skills Match",
"type": "n8n-nodes-base.code",
"position": [
496,
160
],
"parameters": {
"jsCode": "const YOUR_SKILLS = [\n 'n8n', 'automation', 'workflow', 'zapier', 'make.com', 'integromat',\n 'email automation', 'ai', 'gpt', 'openai', 'claude', 'llm',\n 'api integration', 'web scraping', 'python', 'javascript',\n 'aws', 'bedrock', 'langchain', 'chatbot', 'data pipeline'\n];\n\nconst item = $input.first().json;\nconst text = `${item.jobTitle} ${item.jobDescription} ${item.skillsRequired}`.toLowerCase();\n\nconst matchedSkills = YOUR_SKILLS.filter(s => text.includes(s.toLowerCase()));\nconst matchScore = matchedSkills.length;\n\nif (matchScore < 2) {\n return [];\n}\n\nreturn [{\n json: {\n ...item,\n matchScore,\n matchedSkills\n }\n}];"
},
"typeVersion": 2
},
{
"id": "8bfae00c-7cd2-498d-8b29-9f33b7abde40",
"name": "Filter: Client Rating",
"type": "n8n-nodes-base.code",
"position": [
704,
160
],
"parameters": {
"jsCode": "const item = $input.first().json;\n\nconst rating = item.clientRating;\nconst ratingOk = rating === null || rating >= 4.5;\n\nif (!ratingOk) {\n return [];\n}\n\nreturn [{ json: item }];"
},
"typeVersion": 2
},
{
"id": "ad1eaa30-6382-4af5-bffd-0dbfa6941613",
"name": "Airtable: Check Duplicate",
"type": "n8n-nodes-base.airtable",
"position": [
944,
160
],
"parameters": {
"base": {
"__rl": true,
"mode": "list",
"value": "YOUR_AIRTABLE_BASE_ID",
"cachedResultUrl": "",
"cachedResultName": "Leads CRM"
},
"table": {
"__rl": true,
"mode": "list",
"value": "YOUR_AIRTABLE_TABLE_ID",
"cachedResultUrl": "",
"cachedResultName": "Upwork_jobs"
},
"options": {},
"operation": "search",
"filterByFormula": "={Job ID}=\"{{ $json.jobId }}\""
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"typeVersion": 2,
"alwaysOutputData": true
},
{
"id": "2a7186ac-e867-47e7-ae2d-2b27b41887a1",
"name": "Is New Job?",
"type": "n8n-nodes-base.if",
"position": [
1168,
160
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "dup-check",
"operator": {
"type": "string",
"operation": "notExists",
"singleValue": true
},
"leftValue": "={{ $json.id }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2
},
{
"id": "9ac9216b-39d0-4ce0-bd12-1800fc616ef1",
"name": "Extract Proposal Text",
"type": "n8n-nodes-base.code",
"position": [
528,
496
],
"parameters": {
"jsCode": "const input = $input.first().json;\n\nconst proposal = input.message?.content \n || input.text \n || input.choices?.[0]?.message?.content\n || JSON.stringify(input);\n\nconst jobData = $('Filter: Client Rating').first().json;\n\nreturn [{\n json: {\n ...jobData,\n generatedProposal: proposal\n }\n}];"
},
"typeVersion": 2
},
{
"id": "60e4783e-1a69-4efe-8155-66acbbaacc28",
"name": "Airtable: Save Proposal",
"type": "n8n-nodes-base.airtable",
"position": [
752,
496
],
"parameters": {
"base": {
"__rl": true,
"mode": "list",
"value": "YOUR_AIRTABLE_BASE_ID",
"cachedResultUrl": "",
"cachedResultName": "Leads CRM"
},
"table": {
"__rl": true,
"mode": "list",
"value": "YOUR_AIRTABLE_TABLE_ID",
"cachedResultUrl": "",
"cachedResultName": "Upwork_jobs"
},
"columns": {
"value": {
"Budget": "={{ $json.budget }}",
"Job ID": "={{ $json.jobId }}",
"Job URL": "={{ $json.jobUrl }}",
"Job Title": "={{ $json.jobTitle }}",
"Posted At": "={{ $json.postedAt }}",
"upwork_url": "={{ $json.upworkUrl }}",
"AI Proposal": "={{ $json.generatedProposal }}",
"Match Score": "={{ $json.matchScore }}",
"Client Rating": "={{ $json.clientRating }}",
"Matched Skills": "={{ $json.matchedSkills.join(', ') }}",
"Skills Required": "={{ $json.skillsRequired }}",
"Client Total Spent": "={{ $json.clientSpent }}"
},
"schema": [
{
"id": "Job Title",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Job Title",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Job URL",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Job URL",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "upwork_url",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "upwork_url",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Posted At",
"type": "dateTime",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Posted At",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Budget",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Budget",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Skills Required",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Skills Required",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Matched Skills",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Matched Skills",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Match Score",
"type": "number",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Match Score",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Client Rating",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Client Rating",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Client Total Spent",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Client Total Spent",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "AI Proposal",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "AI Proposal",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Status",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Status",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Job ID",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Job ID",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "Notes",
"type": "string",
"display": true,
"removed": false,
"readOnly": false,
"required": false,
"displayName": "Notes",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "create"
},
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"typeVersion": 2
},
{
"id": "b146ef0a-8121-4e03-876c-850ecb5d10c5",
"name": "Build Slack Message",
"type": "n8n-nodes-base.code",
"position": [
976,
496
],
"parameters": {
"jsCode": "const input = $input.first().json;\nconst job = input.fields;\n\nlet proposal = 'Not available';\ntry {\n const parsed = JSON.parse(job['AI Proposal']);\n proposal = parsed.output[0].content[0].text;\n} catch(e) {\n proposal = job['AI Proposal'] || 'Not available';\n}\n\nconst message = `\ud83c\udfaf *New Upwork Job Match!*\n\n*${job['Job Title']}*\n\n\ud83d\udcb0 *Budget:* ${job['Budget'] || 'Not specified'}\n\ud83d\udcca *Match Score:* ${job['Match Score']} skills matched\n\ud83d\udee0\ufe0f *Matched Skills:* ${job['Matched Skills']}\n\ud83d\udcc5 *Posted:* ${job['Posted At']}\n\ud83d\udccb *Skills Required:* ${job['Skills Required']}\n\ud83d\udd17 *Job Link:* ${job['upwork_url']}\n\n\ud83d\udcdd *AI Generated Proposal:*\n${proposal}\n\n\u2705 Saved in Airtable | Job ID: ${job['Job ID']}`;\n\nreturn [{ json: { ...input, slackMessage: message } }];"
},
"typeVersion": 2
},
{
"id": "85039f02-b44c-4e5a-b2a7-d1812cef5639",
"name": "Slack Notification",
"type": "n8n-nodes-base.slack",
"position": [
1200,
496
],
"parameters": {
"text": "={{ $json.slackMessage }}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "list",
"value": "YOUR_SLACK_CHANNEL_ID",
"cachedResultName": "channel-name"
},
"otherOptions": {}
},
"typeVersion": 2.4
},
{
"id": "06ba8fa6-58dd-4e91-bfdf-683ebc4f3a3d",
"name": "AI: Generate Proposal",
"type": "@n8n/n8n-nodes-langchain.openAi",
"position": [
176,
496
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "gpt-4o-mini",
"cachedResultName": "GPT-4O-MINI"
},
"options": {
"maxTokens": 600
},
"responses": {
"values": [
{
"role": "system",
"content": "={{ $json.openAiPayload.messages[0].content }}"
},
{
"content": "={{ $json.openAiPayload.messages[1].content }}"
}
]
},
"builtInTools": {}
},
"typeVersion": 2.1
},
{
"id": "595a2370-08ed-4443-966a-e68e215792d2",
"name": "Build OpenAI Payload",
"type": "n8n-nodes-base.code",
"position": [
-48,
496
],
"parameters": {
"jsCode": "const job = $('Filter: Client Rating').first().json;\n\nconst userMessage = `Write a proposal for this Upwork job:\n\nJOB TITLE: ${job.jobTitle}\n\nJOB DESCRIPTION:\n${job.jobDescription}\n\nBUDGET: ${job.budget}\nSKILLS REQUIRED: ${job.skillsRequired}\nMATCHED SKILLS: ${Array.isArray(job.matchedSkills) ? job.matchedSkills.join(', ') : (job.matchedSkills || 'Not specified')}\n\nMY PROFILE:\n- Name: [YOUR NAME]\n- Core Skills: [YOUR SKILLS]\n- Experience: [YOUR EXPERIENCE]\n- Style: Direct, technical, solution-first. No buzzwords.\n\nWrite a tailored proposal that:\n1. References a SPECIFIC detail from this exact job post in the opening line\n2. Matches my skills precisely to their requirements\n3. Proposes a concrete first step or quick win\n4. Ends with a clear but soft call-to-action`;\n\nconst payload = {\n model: \"gpt-4o-mini\",\n max_tokens: 600,\n messages: [\n {\n role: \"system\",\n content: \"You are an expert Upwork freelancer proposal writer. You write concise, personalized, high-converting proposals that directly address the client's needs. Never use generic templates. Always: (1) Open with a line proving you read the job post carefully, (2) Demonstrate specific relevant experience with real numbers, (3) Propose a clear approach or solution, (4) End with a soft call-to-action. Keep proposals between 150-250 words. No filler words, no fluff.\"\n },\n {\n role: \"user\",\n content: userMessage\n }\n ]\n};\n\nreturn [{\n json: {\n ...job,\n openAiPayload: payload\n }\n}];"
},
"typeVersion": 2
},
{
"id": "a97f2b64-ee71-4510-9bab-6c262cef6ba6",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-752,
64
],
"parameters": {
"width": 620,
"height": 620,
"content": "## How it works\n\nThis workflow monitors Upwork job listings every minute via a Vollna RSS feed. Each new job is parsed to extract the title, description, budget, skills, and a clean Upwork URL. Jobs are scored against your skill list \u2014 only those matching 2 or more of your skills pass through. Low-rated clients are filtered out. Each job is checked against Airtable to avoid processing duplicates.\n\nFor every new qualifying job, GPT-4o-mini writes a personalised 150\u2013250 word proposal referencing details from the actual job post. The proposal and all job data are saved to Airtable with a status of \"New\". A Slack message is sent instantly with the job details, matched skills, and the full proposal ready to copy and submit.\n\n## Setup steps\n\n1. **Vollna** \u2014 Sign up at vollna.com, create a job filter for your skills, and copy the RSS feed URL into the RSS trigger node\n2. **OpenAI** \u2014 Add your API key as an n8n credential and connect it to the AI node\n3. **Airtable** \u2014 Create a base using the schema in the README, then add your Base ID and Table ID to both Airtable nodes\n4. **Slack** \u2014 Create a Slack app with `chat:write` scope, invite it to your channel, and connect it in the Slack node\n5. **Customise** \u2014 Update YOUR_SKILLS in the Filter node and update MY PROFILE in the Build OpenAI Payload node with your actual experience"
},
"typeVersion": 1
},
{
"id": "733d0d39-c7c7-45e1-b188-7defe5adf14f",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-112,
64
],
"parameters": {
"color": 7,
"width": 532,
"height": 310,
"content": "\ud83d\udce1 **Ingest & Parse**\nPolls Vollna RSS every minute. Extracts job title, description, budget, skills, and clean Upwork URL from each item."
},
"typeVersion": 1
},
{
"id": "4e5cfecc-424b-4cb6-b008-0b717dd9c01c",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
432,
64
],
"parameters": {
"color": 7,
"width": 964,
"height": 310,
"content": "\ud83c\udfaf **Filter & Deduplicate**\nNeeds 2+ skill matches. Skips low-rated clients and already-processed jobs."
},
"typeVersion": 1
},
{
"id": "6b7ce2c0-3674-423c-b69a-8dd5c028092d",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-112,
384
],
"parameters": {
"color": 7,
"width": 1508,
"height": 294,
"content": "\ud83e\udd16 **Generate, Save & Notify**\nGPT-4o-mini writes a tailored proposal. Saves to Airtable, sends to Slack."
},
"typeVersion": 1
},
{
"id": "83abc871-4136-4fa4-a163-12b53d7021fc",
"name": "Extract Details from RSS",
"type": "n8n-nodes-base.code",
"position": [
192,
160
],
"parameters": {
"jsCode": "const item = $input.item.json;\n\nfunction extractUpworkUrl(vollnaUrl) {\n try {\n const split = vollnaUrl.split('url=');\n if (split.length < 2) return 'no url param found';\n const raw = split[1];\n const step1 = raw.replace(/%25/g, '%');\n const step2 = decodeURIComponent(step1);\n return step2;\n } catch(e) {\n return 'decode error: ' + e.message;\n }\n}\n\nfunction extractBudget(text) {\n if (!text) return 'Not specified';\n const fixed = text.match(/Budget:\\s*\\$?([\\d,]+)/i);\n const hourly = text.match(/\\$([\\d.]+)\\s*\\/hr/i);\n if (fixed) return `Fixed: $${fixed[1]}`;\n if (hourly) return `Hourly: $${hourly[1]}/hr`;\n return 'Not specified';\n}\n\nfunction extractSkills(text) {\n if (!text) return '';\n const match = text.match(/Skills:\\s*([^\\n<]+)/i);\n return match ? match[1].trim() : '';\n}\n\nfunction extractClientRating(text) {\n if (!text) return null;\n const match = text.match(/([\\d.]+)\\s*of\\s*5/i) || text.match(/Rating:\\s*([\\d.]+)/i);\n return match ? parseFloat(match[1]) : null;\n}\n\nfunction extractClientSpent(text) {\n if (!text) return null;\n const match = text.match(/\\$([\\d,]+)\\+?\\s*spent/i) || text.match(/\\$([\\d,]+)\\+?\\s*total spent/i);\n return match ? match[1].replace(/,/g, '') : null;\n}\n\nfunction generateJobId(url) {\n const match = url?.match(/~([a-zA-Z0-9]+)/);\n return match ? match[1] : url;\n}\n\nconst description = item.contentSnippet || item.content || item.summary || '';\nconst guid = item.guid || item.link || '';\n\nreturn [{\n json: {\n jobTitle: item.title || 'Untitled',\n jobUrl: item.link || item.url || '',\n jobDescription: description,\n postedAt: item.pubDate || item.isoDate || new Date().toISOString(),\n budget: extractBudget(description),\n skillsRequired: extractSkills(description),\n clientRating: extractClientRating(description),\n clientSpent: extractClientSpent(description),\n jobId: generateJobId(item.link || item.url || guid),\n rawGuid: guid,\n upworkUrl: extractUpworkUrl(guid)\n }\n}];"
},
"typeVersion": 2
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "8ceacd4c-087e-4e99-98c6-eb97b50b96f2",
"connections": {
"Is New Job?": {
"main": [
[
{
"node": "Build OpenAI Payload",
"type": "main",
"index": 0
}
]
]
},
"Build Slack Message": {
"main": [
[
{
"node": "Slack Notification",
"type": "main",
"index": 0
}
]
]
},
"Build OpenAI Payload": {
"main": [
[
{
"node": "AI: Generate Proposal",
"type": "main",
"index": 0
}
]
]
},
"Filter: Skills Match": {
"main": [
[
{
"node": "Filter: Client Rating",
"type": "main",
"index": 0
}
]
]
},
"AI: Generate Proposal": {
"main": [
[
{
"node": "Extract Proposal Text",
"type": "main",
"index": 0
}
]
]
},
"Extract Proposal Text": {
"main": [
[
{
"node": "Airtable: Save Proposal",
"type": "main",
"index": 0
}
]
]
},
"Filter: Client Rating": {
"main": [
[
{
"node": "Airtable: Check Duplicate",
"type": "main",
"index": 0
}
]
]
},
"Airtable: Save Proposal": {
"main": [
[
{
"node": "Build Slack Message",
"type": "main",
"index": 0
}
]
]
},
"Extract Details from RSS": {
"main": [
[
{
"node": "Filter: Skills Match",
"type": "main",
"index": 0
}
]
]
},
"Airtable: Check Duplicate": {
"main": [
[
{
"node": "Is New Job?",
"type": "main",
"index": 0
}
]
]
},
"RSS Feed - n8n & Automation": {
"main": [
[
{
"node": "Extract Details from RSS",
"type": "main",
"index": 0
}
]
]
}
}
}
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.
airtableTokenApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow automates the complete Upwork job discovery and proposal generation process by continuously monitoring job listings, intelligently filtering opportunities based on your skill set, generating personalised AI-written proposals, and delivering instant notifications —…
Source: https://n8n.io/workflows/13870/ — 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.
This workflow automatically turns any audio file uploaded to Google Drive into a complete podcast episode. It handles transcription, content generation, blog drafting, social copy creation, thumbnail
inoreader_AI->196267257. Uses httpRequest, openAi, telegram, airtable. Event-driven trigger; 28 nodes.
Automatically qualify, score, and route new leads using a hybrid AI + rule-based scoring engine. This workflow analyzes incoming leads from Airtable, enriches them with OpenAI-powered qualification, a
Some use cases: Sales follow-ups, auto-qualifying leads based on budget, monetizing low-budget leads, and automatic data entry. Ingestion: When a call recording is uploaded to a specific Google Drive
This workflow automatically monitors new Zendesk support tickets, identifies VIP customers, generates AI-based ticket summaries, alerts available support agents on Slack and sends a consolidated email