AutomationFlowsGeneral › Pipeline B - Onboarding to Agent v2

Pipeline B - Onboarding to Agent v2

Pipeline B - Onboarding to Agent v2. Uses manualTrigger. Event-driven trigger; 9 nodes.

Event trigger★★★★☆ complexity9 nodes
General Trigger: Event Nodes: 9 Complexity: ★★★★☆

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
{
  "id": "pipeline-b-onboarding-v2",
  "name": "Pipeline B - Onboarding to Agent v2",
  "nodes": [
    {
      "parameters": {},
      "id": "trigger-b",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// List all recording folders in data/onboarding/\nconst fs = require('fs');\nconst path = require('path');\n\nconst onboardingDir = '/home/node/data/onboarding';\nconst folders = [];\n\ntry {\n  const entries = fs.readdirSync(onboardingDir, { withFileTypes: true });\n  for (const entry of entries) {\n    if (entry.isDirectory() && !entry.name.startsWith('.')) {\n      const folderPath = path.join(onboardingDir, entry.name);\n      const transcriptPath = path.join(folderPath, 'transcript.txt');\n      const chatPath = path.join(folderPath, 'chat.txt');\n      \n      folders.push({\n        json: {\n          folder_name: entry.name,\n          folder_path: folderPath,\n          transcript_path: transcriptPath,\n          chat_path: chatPath,\n          has_transcript: fs.existsSync(transcriptPath),\n          has_chat: fs.existsSync(chatPath)\n        }\n      });\n    }\n  }\n} catch (e) {\n  throw new Error(`Failed to list onboarding folders: ${e.message}`);\n}\n\nif (folders.length === 0) {\n  throw new Error('No recording folders found in /home/node/data/onboarding/');\n}\n\nreturn folders;"
      },
      "id": "list-onboarding",
      "name": "List Onboarding Folders",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Read transcript and chat data\nconst fs = require('fs');\nconst item = $input.item.json;\n\nlet transcript = '';\nlet chatData = { emails: [], phones: [], names: [], raw_lines: [] };\n\nif (item.has_transcript) {\n  transcript = fs.readFileSync(item.transcript_path, 'utf-8');\n} else {\n  throw new Error(`No transcript.txt found in ${item.folder_path}. Run transcribe.py first.`);\n}\n\nif (item.has_chat) {\n  const chatText = fs.readFileSync(item.chat_path, 'utf-8');\n  const lines = chatText.split('\\n').filter(l => l.trim());\n  for (const line of lines) {\n    chatData.raw_lines.push(line.trim());\n    const match = line.match(/From\\s+.+?\\s*:\\s*(.+)/);\n    const content = match ? match[1].trim() : line.trim();\n    const emails = content.match(/[\\w.+-]+@[\\w.-]+\\.\\w+/g);\n    if (emails) chatData.emails.push(...emails);\n    const phones = content.match(/\\b\\d{3}[-.]?\\d{3}[-.]?\\d{4}\\b/g);\n    if (phones) chatData.phones.push(...phones);\n    if (!emails && !phones && content.length < 60 && !/[@\\d]/.test(content) && content.split(' ').length <= 5) {\n      chatData.names.push(content);\n    }\n  }\n}\n\nreturn [{ json: { ...item, transcript, chat_data: chatData } }];"
      },
      "id": "read-onboarding",
      "name": "Read Transcript & Chat",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Call Gemini API to extract onboarding updates\nconst item = $input.item.json;\nconst fs = require('fs');\nconst apiKey = fs.readFileSync('/home/node/scripts/.gemini_key', 'utf-8').trim();\n\nif (!apiKey) {\n  throw new Error('Gemini API key file not found at /home/node/scripts/.gemini_key');\n}\n\nconst prompt = `You are an account information extractor for Clara Answers, an AI-powered voice agent for service trade businesses.\n\nThis is an ONBOARDING call transcript. The client has already purchased and is now providing OPERATIONAL DETAILS for configuring their agent. Extract precise, confirmed operational information.\n\nRules:\n- Extract ONLY explicitly stated information\n- This is configuration data \u2014 be precise with hours, numbers, routing rules\n- If a field is not discussed, set it to null (do not carry over demo assumptions)\n- Focus on: exact business hours, emergency definitions, routing rules, transfer protocols, special constraints\n\nOutput ONLY valid JSON (no markdown, no code fences) with the same schema:\n{\n  \"company_name\": \"string\",\n  \"business_hours\": { \"days\": \"string\", \"start\": \"string\", \"end\": \"string\", \"timezone\": \"string or null\" },\n  \"office_address\": \"string or null\",\n  \"services_supported\": [\"list of services\"],\n  \"emergency_definition\": \"string or null\",\n  \"emergency_routing_rules\": { \"who_to_call\": \"string\", \"order\": \"string\", \"fallback\": \"string\" },\n  \"non_emergency_routing_rules\": \"string or null\",\n  \"call_transfer_rules\": { \"timeout_seconds\": \"number\", \"retries\": \"number\", \"fallback_message\": \"string\" },\n  \"integration_constraints\": \"string or null\",\n  \"after_hours_flow_summary\": \"string\",\n  \"office_hours_flow_summary\": \"string\",\n  \"questions_or_unknowns\": [\"list of missing/unclear items\"],\n  \"notes\": \"string\",\n  \"contact_info\": { \"primary_email\": \"string\", \"primary_phone\": \"string\", \"contact_name\": \"string\" },\n  \"pricing\": { \"service_call_fee\": \"string\", \"hourly_rate\": \"string\", \"details\": \"string\" },\n  \"special_customers\": [{ \"name\": \"string\", \"contact\": \"string\", \"phone\": \"string\", \"email\": \"string\", \"notes\": \"string\" }]\n}\n\nTRANSCRIPT:\n${item.transcript}\n\nCHAT DATA:\n${JSON.stringify(item.chat_data)}`;\n\nconst https = require('https');\n\nconst postData = JSON.stringify({\n  contents: [{ parts: [{ text: prompt }] }],\n  generationConfig: { temperature: 0, maxOutputTokens: 8192 }\n});\n\nconst data = await new Promise((resolve, reject) => {\n  const req = https.request({\n    hostname: 'generativelanguage.googleapis.com',\n    path: `/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`,\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }\n  }, (res) => {\n    let body = '';\n    res.on('data', (chunk) => body += chunk);\n    res.on('end', () => {\n      if (res.statusCode !== 200) reject(new Error(`Gemini API error (${res.statusCode}): ${body.substring(0, 500)}`));\n      else {\n        try { resolve(JSON.parse(body)); }\n        catch (pe) { reject(new Error(`Failed to parse Gemini response body: ${pe.message}. Body: ${body.substring(0, 500)}`)); }\n      }\n    });\n  });\n  req.on('error', reject);\n  req.write(postData);\n  req.end();\n});\n\nreturn [{ json: { ...data, _prev: { folder_name: item.folder_name, folder_path: item.folder_path, chat_data: item.chat_data } } }];"
      },
      "id": "gemini-onboarding",
      "name": "Gemini Extract Updates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Parse Gemini response, find v1 memo, and patch\nconst crypto = require('crypto');\nconst fs = require('fs');\nconst path = require('path');\n\nconst item = $input.item.json;\nconst prevData = item._prev || {};\n\n// Extract text from Gemini response\nlet responseText = '';\ntry {\n  const parts = item.candidates[0].content.parts;\n  for (const part of parts) {\n    if (part.text) {\n      const trimmed = part.text.trim();\n      if (trimmed.startsWith('{')) {\n        responseText = trimmed;\n        break;\n      }\n    }\n  }\n  if (!responseText) {\n    responseText = parts[parts.length - 1].text.trim();\n  }\n} catch (e) {\n  throw new Error(`Failed to parse Gemini response: ${JSON.stringify(item).substring(0, 1000)}`);\n}\n\nresponseText = responseText.replace(/```json\\n?/g, '').replace(/```\\n?/g, '').trim();\n\n// Parse JSON with sanitization for common LLM issues\nfunction sanitizeAndParse(text) {\n  try { return JSON.parse(text); } catch(e) {}\n  const fixed = text.replace(/(?<=:\\s*\")([^\"]*?)\\n([^\"]*?)(?=\")/g, (m) => m.replace(/\\n/g, '\\\\n'));\n  try { return JSON.parse(fixed); } catch(e) {}\n  const jsonMatch = text.match(/\\{[\\s\\S]*\\}/);\n  if (jsonMatch) {\n    try { return JSON.parse(jsonMatch[0]); } catch(e) {}\n    const fixed2 = jsonMatch[0].replace(/(?<=:\\s*\")([^\"]*?)\\n([^\"]*?)(?=\")/g, (m) => m.replace(/\\n/g, '\\\\n'));\n    try { return JSON.parse(fixed2); } catch(e) {}\n  }\n  return null;\n}\n\nlet updates = sanitizeAndParse(responseText);\nif (!updates) {\n  throw new Error(`Gemini returned invalid JSON. First 1000 chars: ${responseText.substring(0, 1000)}`);\n}\n\n// Resolve account_id \u2014 try exact hash first, then scan all accounts for fuzzy match\nconst companyName = updates.company_name || 'unknown';\nconst hash = crypto.createHash('sha256').update(companyName.trim().toLowerCase()).digest('hex').substring(0, 12);\nlet accountId = `acct_${hash}`;\n\nconst accountsDir = '/home/node/outputs/accounts';\nlet v1MemoPath = path.join(accountsDir, accountId, 'v1', 'memo.json');\n\nif (!fs.existsSync(v1MemoPath)) {\n  // Scan all account folders for a company name match\n  const normalize = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');\n  const target = normalize(companyName);\n  let found = false;\n  \n  if (fs.existsSync(accountsDir)) {\n    const dirs = fs.readdirSync(accountsDir);\n    for (const dir of dirs) {\n      const candidatePath = path.join(accountsDir, dir, 'v1', 'memo.json');\n      if (fs.existsSync(candidatePath)) {\n        const candidateMemo = JSON.parse(fs.readFileSync(candidatePath, 'utf-8'));\n        const candidateName = normalize(candidateMemo.company_name || '');\n        // Check if names are similar (one contains the other, or >80% overlap)\n        if (target.includes(candidateName) || candidateName.includes(target) || target === candidateName) {\n          accountId = dir;\n          v1MemoPath = candidatePath;\n          found = true;\n          break;\n        }\n      }\n    }\n  }\n  \n  if (!found) {\n    throw new Error(`No v1 memo found for '${companyName}' (tried ${accountId} and scanned ${accountsDir}). Run Pipeline A first.`);\n  }\n}\n\nconst v1Memo = JSON.parse(fs.readFileSync(v1MemoPath, 'utf-8'));\n\n// Patch memo: onboarding overrides demo, null doesn't regress\nconst merged = JSON.parse(JSON.stringify(v1Memo)); // deep clone\nconst changes = [];\n\nfor (const [key, newVal] of Object.entries(updates)) {\n  if (key === 'account_id') continue;\n  if (newVal === null || newVal === undefined) continue;\n  \n  const oldVal = v1Memo[key];\n  \n  if (oldVal === null || oldVal === undefined) {\n    merged[key] = newVal;\n    changes.push({ field: key, action: 'filled', old: null, new: newVal, source: 'onboarding' });\n  } else if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {\n    merged[key] = newVal;\n    changes.push({ field: key, action: 'updated', old: oldVal, new: newVal, source: 'onboarding' });\n  }\n}\n\n// Remove resolved items from questions_or_unknowns\nif (merged.questions_or_unknowns && Array.isArray(merged.questions_or_unknowns)) {\n  const filledFields = new Set(changes.filter(c => c.action === 'filled').map(c => c.field));\n  merged.questions_or_unknowns = merged.questions_or_unknowns.filter(\n    q => !Array.from(filledFields).some(f => q.includes(f))\n  );\n}\n\nmerged.account_id = accountId;\n\nreturn [{\n  json: {\n    account_id: accountId,\n    company_name: companyName,\n    v1_memo: v1Memo,\n    v2_memo: merged,\n    changes: changes,\n    folder_name: prevData.folder_name,\n    folder_path: prevData.folder_path\n  }\n}];"
      },
      "id": "patch-memo",
      "name": "Patch v1 \u2192 v2 Memo",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Generate v2 Retell Agent Spec (same template as Pipeline A but with v2 memo)\nconst item = $input.item.json;\nconst memo = item.v2_memo;\n\nconst companyName = memo.company_name || 'Unknown Company';\nconst agentName = `${companyName} Clara Agent`;\n\nlet businessHoursText = 'Monday to Friday, 8:00 AM to 5:00 PM';\nif (memo.business_hours) {\n  const bh = memo.business_hours;\n  businessHoursText = `${bh.days || 'Monday to Friday'}, ${bh.start || '8:00 AM'} to ${bh.end || '5:00 PM'}`;\n  if (bh.timezone) businessHoursText += ` (${bh.timezone})`;\n}\n\nconst emergencyDef = memo.emergency_definition || 'The caller states it is an emergency or describes an active hazard.';\n\nlet emergencyRouting = 'Attempt to transfer to the on-call team.';\nif (memo.emergency_routing_rules) {\n  const er = memo.emergency_routing_rules;\n  emergencyRouting = `Transfer to: ${er.who_to_call || 'on-call team'}. Order: ${er.order || 'primary contact first'}. Fallback: ${er.fallback || 'Take message and assure follow-up.'}`;\n}\n\nconst nonEmergencyRouting = memo.non_emergency_routing_rules || 'Collect details and confirm follow-up during next business day.';\n\nlet transferFallback = 'Apologize, confirm all details have been collected, and assure the caller someone will follow up.';\nif (memo.call_transfer_rules && memo.call_transfer_rules.fallback_message) {\n  transferFallback = memo.call_transfer_rules.fallback_message;\n}\n\nconst integrationConstraints = memo.integration_constraints ? `Important: ${memo.integration_constraints}` : '';\n\nlet pricingSection = '';\nif (memo.pricing) {\n  pricingSection = `\\n## Pricing Information (share only when asked)\\n`;\n  if (memo.pricing.service_call_fee) pricingSection += `- Service call fee: ${memo.pricing.service_call_fee}\\n`;\n  if (memo.pricing.hourly_rate) pricingSection += `- Hourly rate: ${memo.pricing.hourly_rate}\\n`;\n  if (memo.pricing.details) pricingSection += `- Details: ${memo.pricing.details}\\n`;\n}\n\nlet specialCustomersSection = '';\nif (memo.special_customers && memo.special_customers.length > 0) {\n  specialCustomersSection = `\\n## Special Customers (Priority After-Hours)\\n`;\n  for (const cust of memo.special_customers) {\n    specialCustomersSection += `- ${cust.name}: ${cust.notes || ''}\\n`;\n    if (cust.contact) specialCustomersSection += `  Contact: ${cust.contact}\\n`;\n    if (cust.phone) specialCustomersSection += `  Phone: ${cust.phone}\\n`;\n  }\n}\n\nconst systemPrompt = `You are ${agentName}, the AI phone assistant for ${companyName}.\nYou handle incoming calls professionally, warmly, and efficiently.\n\n## Business Hours\n${businessHoursText}\n\n## During Business Hours - Call Flow\n1. Greet the caller warmly: \"Thank you for calling ${companyName}, this is Clara. How can I help you today?\"\n2. Listen to their request and determine the purpose of the call.\n3. Collect their full name and a callback number.\n4. Based on their request:\n   - For service requests or appointments: Collect details about the issue, preferred timing, and their address. Confirm you will have someone follow up to schedule.\n   - For existing customers checking on a job: Take their name and details, confirm someone will call them back.\n   - For urgent issues: Attempt to transfer the call to ${memo.emergency_routing_rules?.who_to_call || 'the team'}.\n5. If a transfer is attempted and fails: ${transferFallback}\n6. Confirm next steps with the caller.\n7. Ask: \"Is there anything else I can help you with?\"\n8. If no: \"Thank you for calling ${companyName}. Have a great day!\"\n\n## After Hours - Call Flow\n1. Greet the caller: \"Thank you for calling ${companyName}. Our office is currently closed. Our business hours are ${businessHoursText}.\"\n2. Ask: \"How can I help you?\"\n3. Determine if this is an emergency.\n   Emergency is defined as: ${emergencyDef}\n4. IF EMERGENCY:\n   a. Collect the caller's full name, callback number, and service address.\n   b. ${emergencyRouting}\n   c. If transfer fails: ${transferFallback}\n5. IF NOT EMERGENCY:\n   a. Collect the caller's name, callback number, and a brief description of what they need.\n   b. Confirm: \"I'll make sure someone follows up with you on the next business day.\"\n   c. ${nonEmergencyRouting}\n6. Ask: \"Is there anything else I can help you with?\"\n7. If no: \"Thank you for calling ${companyName}. Have a good evening!\"\n\n## Rules\n- Be professional, friendly, and concise.\n- Only collect information needed for routing and dispatch.\n- Never mention internal systems, tools, or technical processes to the caller.\n- If you are unsure about something, be honest and let the caller know someone will follow up.\n- Do not ask too many questions. Keep the conversation focused and efficient.\n${integrationConstraints}\n${pricingSection}\n${specialCustomersSection}`.trim();\n\nconst agentSpec = {\n  agent_name: agentName,\n  voice_style: 'professional, friendly, calm',\n  system_prompt: systemPrompt,\n  begin_message: `Thank you for calling ${companyName}, this is Clara. How can I help you today?`,\n  key_variables: {\n    timezone: memo.business_hours?.timezone || null,\n    business_hours: memo.business_hours || null,\n    office_address: memo.office_address || null,\n    emergency_routing: memo.emergency_routing_rules || null,\n    company_name: companyName\n  },\n  tool_invocation_placeholders: {\n    end_call: { type: 'end_call', description: 'End the call after caller confirms no further needs' },\n    transfer_call: { type: 'transfer_call', description: 'Transfer to on-call or office staff', transfer_to: memo.contact_info?.primary_phone || null, warm_transfer: true },\n    create_ticket: { type: 'custom_function', description: 'Log call details for follow-up dispatch' }\n  },\n  call_transfer_protocol: {\n    primary: memo.emergency_routing_rules || null,\n    timeout_seconds: memo.call_transfer_rules?.timeout_seconds || 60,\n    retries: memo.call_transfer_rules?.retries || 1,\n    on_fail_message: transferFallback\n  },\n  fallback_protocol: transferFallback,\n  version: 'v2'\n};\n\nreturn [{ json: { ...item, agent_spec: agentSpec } }];"
      },
      "id": "generate-v2-spec",
      "name": "Generate v2 Agent Spec",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Save v2 outputs and changelog\nconst fs = require('fs');\nconst path = require('path');\n\nconst item = $input.item.json;\nconst accountId = item.account_id;\nconst outputDir = path.join('/home/node/outputs/accounts', accountId, 'v2');\n\nfs.mkdirSync(outputDir, { recursive: true });\n\n// Write v2 memo\nfs.writeFileSync(path.join(outputDir, 'memo.json'), JSON.stringify(item.v2_memo, null, 2), 'utf-8');\n\n// Write v2 agent spec\nfs.writeFileSync(path.join(outputDir, 'agent_spec.json'), JSON.stringify(item.agent_spec, null, 2), 'utf-8');\n\n// Write changelog\nconst changelog = {\n  account_id: accountId,\n  company_name: item.company_name,\n  from_version: 'v1',\n  to_version: 'v2',\n  source: 'onboarding',\n  source_folder: item.folder_name,\n  changes: item.changes,\n  total_changes: item.changes.length,\n  changed_at: new Date().toISOString()\n};\nfs.writeFileSync(path.join(outputDir, 'changes.json'), JSON.stringify(changelog, null, 2), 'utf-8');\n\n// Write status\nconst status = {\n  account_id: accountId,\n  company_name: item.company_name,\n  version: 'v2',\n  source_folder: item.folder_name,\n  pipeline: 'pipeline_b',\n  changes_count: item.changes.length,\n  created_at: new Date().toISOString(),\n  status: 'success'\n};\nfs.writeFileSync(path.join(outputDir, 'status.json'), JSON.stringify(status, null, 2), 'utf-8');\n\nreturn [{\n  json: {\n    account_id: accountId,\n    company_name: item.company_name,\n    output_dir: outputDir,\n    changes_count: item.changes.length,\n    status: 'success'\n  }\n}];"
      },
      "id": "save-v2",
      "name": "Save v2 Outputs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst results = items.map(item => ({\n  account_id: item.json.account_id,\n  company_name: item.json.company_name,\n  changes_count: item.json.changes_count,\n  status: item.json.status\n}));\n\nreturn [{\n  json: {\n    pipeline: 'Pipeline B - Onboarding to Agent v2',\n    processed: results.length,\n    results: results,\n    timestamp: new Date().toISOString()\n  }\n}];"
      },
      "id": "summary-b",
      "name": "Pipeline Summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1760,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Update Asana Task\nconst fs = require('fs');\nconst https = require('https');\nconst path = require('path');\n\nconst item = $input.item.json;\nconst accountId = item.account_id;\nconst companyName = item.company_name;\n\nlet asanaToken = '';\nlet asanaProjectId = '';\ntry {\n  asanaToken = fs.readFileSync('/home/node/scripts/.asana_token', 'utf-8').trim();\n  asanaProjectId = fs.readFileSync('/home/node/scripts/.asana_project_id', 'utf-8').trim();\n} catch (e) {\n  return [{ json: { ...item, asana_status: 'skipped (no credentials)' } }];\n}\n\nif (!asanaToken || !asanaProjectId) {\n  return [{ json: { ...item, asana_status: 'skipped (missing credentials)' } }];\n}\n\nlet asanaTaskGid = null;\ntry {\n  const v1StatusPath = `/home/node/outputs/accounts/${accountId}/v1/status.json`;\n  if (fs.existsSync(v1StatusPath)) {\n    const v1Status = JSON.parse(fs.readFileSync(v1StatusPath, 'utf-8'));\n    asanaTaskGid = v1Status.asana_task_gid;\n  }\n} catch (e) {\n  console.error('Failed to read v1 status for Asana GID', e);\n}\n\nlet v2Memo = {};\ntry {\n  v2Memo = JSON.parse(fs.readFileSync(`/home/node/outputs/accounts/${accountId}/v2/memo.json`, 'utf-8'));\n} catch (e) {\n  v2Memo = { questions_or_unknowns: [] };\n}\n\nconst unknownsCount = Array.isArray(v2Memo.questions_or_unknowns) ? v2Memo.questions_or_unknowns.length : 0;\nconst isComplete = unknownsCount === 0;\n\nlet changesSummary = '';\nlet changesCount = 0;\nlet filledCount = 0;\ntry {\n  const changesData = JSON.parse(fs.readFileSync(`/home/node/outputs/accounts/${accountId}/v2/changes.json`, 'utf-8'));\n  const changes = changesData.changes || [];\n  changesCount = changes.length;\n  filledCount = changes.filter(c => c.action === 'filled').length;\n  changesSummary = `\\n\\n---\\n\\nv2 Updates:\\n- ${changesCount} fields updated\\n- ${filledCount} fields filled\\n- ${isComplete ? 'All unknowns resolved' : `${unknownsCount} unknowns remaining`}`;\n} catch (e) {\n  console.error('Failed to read changes.json', e);\n  changesSummary = `\\n\\n---\\n\\nv2 Updates Applied. Unknowns remaining: ${unknownsCount}`;\n}\n\nconst taskName = `[${accountId}] ${companyName} \u2014 Agent Config v2`;\n\nasync function asanaRequest(method, endpoint, body = null) {\n  return new Promise((resolve, reject) => {\n    const options = {\n      hostname: 'app.asana.com',\n      path: endpoint,\n      method: method,\n      headers: {\n        'Authorization': `Bearer ${asanaToken}`\n      }\n    };\n    let postData = null;\n    if (body) {\n      postData = JSON.stringify(body);\n      options.headers['Content-Type'] = 'application/json';\n      options.headers['Content-Length'] = Buffer.byteLength(postData);\n    }\n    const req = https.request(options, (res) => {\n      let data = '';\n      res.on('data', (chunk) => data += chunk);\n      res.on('end', () => {\n        if (res.statusCode >= 200 && res.statusCode < 300) {\n          try { resolve(JSON.parse(data)); } catch (e) { resolve({ error: 'Parse error' }); }\n        } else {\n          resolve({ error: `HTTP ${res.statusCode}`, message: data });\n        }\n      });\n    });\n    req.on('error', (e) => resolve({ error: e.message }));\n    if (postData) req.write(postData);\n    req.end();\n  });\n}\n\nlet finalStatus = 'failed';\n\nif (asanaTaskGid) {\n  const getRes = await asanaRequest('GET', `/api/1.0/tasks/${asanaTaskGid}`);\n  let currentNotes = '';\n  if (!getRes.error && getRes.data && getRes.data.notes) {\n    currentNotes = getRes.data.notes;\n  }\n  \n  const updatePayload = {\n    data: {\n      name: taskName,\n      notes: currentNotes + changesSummary\n    }\n  };\n  \n  if (isComplete) {\n    updatePayload.data.completed = true;\n  }\n  \n  const putRes = await asanaRequest('PUT', `/api/1.0/tasks/${asanaTaskGid}`, updatePayload);\n  if (!putRes.error) {\n    finalStatus = 'updated';\n  } else {\n    console.error('Failed to update Asana task:', putRes);\n  }\n} else {\n  const createPayload = {\n    data: {\n      name: taskName,\n      projects: [asanaProjectId],\n      notes: `Account ID: ${accountId}\\nCompany: ${companyName}\\n` + changesSummary,\n      completed: isComplete\n    }\n  };\n  const postRes = await asanaRequest('POST', `/api/1.0/tasks`, createPayload);\n  if (!postRes.error && postRes.data && postRes.data.gid) {\n    asanaTaskGid = postRes.data.gid;\n    finalStatus = 'created';\n  } else {\n    console.error('Failed to create Asana task:', postRes);\n  }\n}\n\nif (asanaTaskGid) {\n  try {\n    const v2StatusPath = `/home/node/outputs/accounts/${accountId}/v2/status.json`;\n    if (fs.existsSync(v2StatusPath)) {\n      const v2Status = JSON.parse(fs.readFileSync(v2StatusPath, 'utf-8'));\n      v2Status.asana_task_gid = asanaTaskGid;\n      fs.writeFileSync(v2StatusPath, JSON.stringify(v2Status, null, 2), 'utf-8');\n    }\n  } catch (e) {\n    console.error('Failed to update v2 status.json with Asana GID', e);\n  }\n}\n\nreturn [{\n  json: {\n    ...item,\n    asana_task_gid: asanaTaskGid,\n    asana_status: finalStatus\n  }\n}];"
      },
      "id": "update-asana-task",
      "name": "Update Asana Task",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        0
      ]
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "List Onboarding Folders",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List Onboarding Folders": {
      "main": [
        [
          {
            "node": "Read Transcript & Chat",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Transcript & Chat": {
      "main": [
        [
          {
            "node": "Gemini Extract Updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Gemini Extract Updates": {
      "main": [
        [
          {
            "node": "Patch v1 \u2192 v2 Memo",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Patch v1 \u2192 v2 Memo": {
      "main": [
        [
          {
            "node": "Generate v2 Agent Spec",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate v2 Agent Spec": {
      "main": [
        [
          {
            "node": "Save v2 Outputs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save v2 Outputs": {
      "main": [
        [
          {
            "node": "Update Asana Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update Asana Task": {
      "main": [
        [
          {
            "node": "Pipeline Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}

About this workflow

Pipeline B - Onboarding to Agent v2. Uses manualTrigger. Event-driven trigger; 9 nodes.

Source: https://github.com/Kss004/ClaraAIAssignment/blob/98be75ad85b423f8d29df77383f119088419a21c/workflows/pipeline_b.json — original creator credit. Request a take-down →

More General workflows → · Browse all categories →