The **Credence Creator Search and Scoring Agent** powers the discovery and ranking stage of the product. In the current prototype, the Credence frontend sends a campaign brief and a list of candidate creator profiles to an n8n webhook. The workflow then evaluates each creator against campaign requirements such as target audience, industry, geography, language, campaign objective, and preferred creator type. The present workflow ranks supplied creator candidates rather than independently querying LinkedIn or an external creator database; database-backed search is part of the broader product roadmap.
The first stage uses a deterministic JavaScript scoring model inside an n8n Code node. It normalizes the campaign and creator text, extracts relevant keywords, and measures overlap between the campaign brief and profile information such as the creator’s title, category, content themes, audience summary, and recent posts. It also checks explicit matches for region, language, and creator type. These signals are combined into four scores:

The algorithm also produces a preliminary fair price band, match explanation, recommended collaboration format, and risk note. Creators are then sorted from highest to lowest Campaign Fit Score. Importantly, follower count contributes only a small boost and is not treated as the main measure of influence, consistent with Credence’s trust-first product principle.
The highest-ranked creators—up to the top 12 profiles—are then passed to a language model through n8n’s LangChain LLM Chain and OpenRouter Chat Model nodes. The exact underlying AI model is not specified in the workflow export and can be selected through OpenRouter. The model is configured for up to approximately 3,000 output tokens and is instructed to interpret the campaign intent and enrich each result with concise score rationales, match reasons, risk notes, recommended use cases, outreach angles, and confidence levels. It is explicitly prevented from inventing creators, removing candidates, changing creator IDs, or modifying the deterministic scores.
Finally, the workflow parses the model response and merges the explanations back into the original ranked creator records using each creator’s immutable ID. If the AI output is incomplete or invalid, the system falls back to the deterministic explanations so the workflow can still return usable results. The final webhook response includes the interpreted campaign intent and an explainable, fit-ranked creator shortlist that can be displayed in Credence for comparison, shortlisting, and subsequent outreach generation.
n8n, OpenRouter AI Models, and Javascript logic.
(Replaced sensitive/public-risk fields including credential IDs/names, Google Sheets document ID and URLs, Slack channel ID/name, webhook IDs, node IDs, workflow/version IDs, and the n8n instance ID.)
{
"name": "Credence Score Creator",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "credence-score-creators",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
0,
0
],
"id": "348cfa56-728a-40c4-8314-165a728b8450",
"name": "Webhook",
"webhookId": "eff6a125-0c55-4cd0-9093-94ff91841311"
},
{
"parameters": {
"jsCode": "const input = $input.first().json;\nconst body = input.body ?? input;\n\nconst brief = body.brief ?? {};\nconst creators = Array.isArray(body.creators) ? body.creators : [];\n\nfunction clamp(value, min = 0, max = 100) {\n return Math.max(min, Math.min(max, Math.round(value)));\n}\n\nfunction toText(value) {\n if (Array.isArray(value)) return value.join(\" \");\n if (value && typeof value === \"object\") return JSON.stringify(value);\n return String(value ?? \"\");\n}\n\nfunction normalize(value) {\n return toText(value).toLowerCase();\n}\n\nconst stopwords = new Set([\n \"and\", \"the\", \"for\", \"with\", \"from\", \"this\", \"that\", \"into\", \"about\",\n \"your\", \"their\", \"they\", \"are\", \"was\", \"were\", \"will\", \"have\", \"has\",\n \"our\", \"you\", \"his\", \"her\", \"its\", \"who\", \"what\", \"why\", \"how\"\n]);\n\nfunction tokens(value) {\n return normalize(value)\n .replace(/[^a-z0-9\\s]/g, \" \")\n .split(/\\s+/)\n .filter((token) => token.length > 2 && !stopwords.has(token));\n}\n\nfunction overlapScore(source, target) {\n const sourceTokens = new Set(tokens(source));\n const targetTokens = new Set(tokens(target));\n\n if (sourceTokens.size === 0 || targetTokens.size === 0) return 0;\n\n let matches = 0;\n for (const token of targetTokens) {\n if (sourceTokens.has(token)) matches += 1;\n }\n\n return Math.min(100, (matches / Math.max(3, targetTokens.size)) * 100);\n}\n\nfunction hasSoftMatch(source, target) {\n const s = normalize(source);\n const t = normalize(target);\n if (!s || !t) return false;\n return s.includes(t) || t.includes(s);\n}\n\nfunction getPriceBand(followers, campaignFit) {\n if (campaignFit >= 88 && followers >= 40000) return \"₹45,000–₹75,000\";\n if (campaignFit >= 82 && followers >= 20000) return \"₹30,000–₹55,000\";\n if (campaignFit >= 75 && followers >= 10000) return \"₹20,000–₹40,000\";\n return \"₹12,000–₹25,000\";\n}\n\nfunction getRecommendedUseCase(creator) {\n const category = normalize(creator.category || creator.title);\n\n if (category.includes(\"founder\")) {\n return \"Founder-led thought leadership post\";\n }\n\n if (category.includes(\"operator\") || category.includes(\"product\")) {\n return \"Tactical workflow breakdown or product-led LinkedIn post\";\n }\n\n if (category.includes(\"educator\") || category.includes(\"career\")) {\n return \"Educational explainer post or carousel-style breakdown\";\n }\n\n if (category.includes(\"finance\")) {\n return \"Trust-building educational content with practical examples\";\n }\n\n if (category.includes(\"hr\") || category.includes(\"talent\")) {\n return \"Hiring, workplace, or talent-community campaign\";\n }\n\n return \"LinkedIn awareness post with practical creator-led explanation\";\n}\n\nfunction getRiskNote({ regionMatch, languageMatch, engagementQuality, campaignFit }) {\n if (!regionMatch) {\n return \"Region fit is approximate; validate whether the creator’s actual audience geography matches the campaign.\";\n }\n\n if (!languageMatch) {\n return \"Language fit may need validation before outreach.\";\n }\n\n if (engagementQuality < 70) {\n return \"Engagement quality should be checked manually before final selection.\";\n }\n\n if (campaignFit < 75) {\n return \"Campaign fit is moderate; use this creator only if budget or niche availability is constrained.\";\n }\n\n return \"No major fit risk detected from the available mock profile data.\";\n}\n\nconst campaignText = [\n brief.campaignName,\n brief.brandName,\n brief.targetAudience,\n brief.industry,\n brief.region,\n brief.language,\n brief.goal,\n brief.creatorType\n].join(\" \");\n\nconst rankedCreators = creators.map((creator) => {\n const creatorText = [\n creator.name,\n creator.title,\n creator.city,\n creator.region,\n creator.language,\n creator.category,\n creator.summary,\n creator.contentThemes,\n creator.audienceSummary,\n creator.recentPosts\n ].join(\" \");\n\n const followers = Number(creator.followers || creator.followerCount || 0);\n\n const regionMatch =\n !brief.region ||\n hasSoftMatch(`${creator.region} ${creator.city}`, brief.region);\n\n const languageMatch =\n !brief.language ||\n hasSoftMatch(creator.language, brief.language);\n\n const typeMatch =\n !brief.creatorType ||\n hasSoftMatch(`${creator.category} ${creator.title}`, brief.creatorType);\n\n const industryOverlap = overlapScore(creatorText, `${brief.industry} ${brief.goal}`);\n const audienceOverlap = overlapScore(\n `${creator.audienceSummary} ${creatorText}`,\n `${brief.targetAudience} ${brief.industry}`\n );\n const goalOverlap = overlapScore(creatorText, brief.goal);\n\n const themeCount = Array.isArray(creator.contentThemes)\n ? creator.contentThemes.length\n : tokens(creator.contentThemes).length;\n\n const recentPostCount = Array.isArray(creator.recentPosts)\n ? creator.recentPosts.length\n : tokens(creator.recentPosts).length > 0\n ? 1\n : 0;\n\n const followerBoost =\n followers >= 100000 ? 6 :\n followers >= 50000 ? 5 :\n followers >= 20000 ? 4 :\n followers >= 10000 ? 3 :\n followers >= 5000 ? 2 :\n 1;\n\n const authority = clamp(\n 62 +\n industryOverlap * 0.18 +\n goalOverlap * 0.08 +\n (typeMatch ? 8 : 0) +\n Math.min(themeCount * 2, 8),\n 45,\n 96\n );\n\n const audienceFit = clamp(\n 55 +\n audienceOverlap * 0.24 +\n (regionMatch ? 8 : 0) +\n (languageMatch ? 7 : 0) +\n (typeMatch ? 5 : 0),\n 40,\n 97\n );\n\n const engagementQuality = clamp(\n 62 +\n Math.min(recentPostCount * 4, 10) +\n Math.min(themeCount * 2, 8) +\n followerBoost,\n 45,\n 92\n );\n\n const campaignFit = clamp(\n authority * 0.30 +\n audienceFit * 0.35 +\n engagementQuality * 0.20 +\n goalOverlap * 0.10 +\n (typeMatch ? 5 : 0),\n 40,\n 98\n );\n\n const fairPriceBand = creator.fairPriceBand || creator.priceBand || getPriceBand(followers, campaignFit);\n\n const matchReason = `${creator.name} is a strong fit for ${brief.brandName || brief.campaignName || \"this campaign\"} because their content overlaps with ${brief.industry || \"the campaign niche\"} and their audience is relevant to ${brief.targetAudience || \"the target audience\"}${regionMatch && brief.region ? ` in ${brief.region}` : \"\"}.`;\n\n const riskNote = getRiskNote({\n regionMatch,\n languageMatch,\n engagementQuality,\n campaignFit\n });\n\n return {\n ...creator,\n scores: {\n authority,\n audienceFit,\n engagementQuality,\n campaignFit\n },\n fairPriceBand,\n matchReason,\n riskNote,\n recommendedUseCase: getRecommendedUseCase(creator)\n };\n}).sort((a, b) => b.scores.campaignFit - a.scores.campaignFit);\n\nreturn [\n {\n json: {\n workflow: \"credence-score-creators\",\n mode: \"deterministic-demo-scoring\",\n brief,\n rankedCreators\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
224,
0
],
"id": "1b924703-fdb8-4163-bc41-95286cf1c3a3",
"name": "Score and Rank Creators"
},
{
"parameters": {
"options": {
"responseCode": 200
}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.5,
"position": [
1248,
0
],
"id": "10d834f6-357d-4276-ae03-a3db14a51a20",
"name": "Respond to Webhook"
},
{
"parameters": {
"jsCode": "const input = $input.first().json;\n\nconst brief = input.brief ?? {};\nconst rankedCreators = Array.isArray(input.rankedCreators)\n ? input.rankedCreators\n : [];\n\n// Keep prompt small enough for reliability.\n// Send top 12 creators to the LLM for explanation enrichment.\nconst creatorsForLLM = rankedCreators.slice(0, 12).map((creator) => ({\n id: creator.id,\n name: creator.name,\n title: creator.title,\n city: creator.city,\n region: creator.region,\n language: creator.language,\n category: creator.category,\n followers: creator.followers || creator.followerCount,\n summary: creator.summary,\n contentThemes: creator.contentThemes,\n audienceSummary: creator.audienceSummary,\n recentPosts: creator.recentPosts,\n scores: creator.scores,\n fairPriceBand: creator.fairPriceBand\n}));\n\nconst llmInput = {\n brief,\n creators: creatorsForLLM\n};\n\nconst chatInput = `\nYou are the AI scoring and explanation engine for Credence, a LinkedIn creator discovery product.\n\nCredence helps brands discover credible LinkedIn creators for regional professional communities.\n\nYou will receive:\n1. A campaign brief\n2. A deterministically ranked list of mock LinkedIn creators\n3. Baseline scores generated by a scoring algorithm\n\nYour task:\nEnrich the ranked creator list with:\n- campaign intent interpretation\n- score rationale\n- match reasons\n- risk notes\n- recommended use cases\n- outreach angles\n- confidence levels\n\nImportant rules:\n- Do not invent new creators.\n- Do not remove creators.\n- Do not change creator IDs.\n- Creator IDs are immutable. Return the exact same creator IDs from the input.\n- Do not over-index on follower count.\n- Prioritize authority, audience fit, engagement quality, regional/language fit, and campaign relevance.\n- Keep output concise and useful for a brand marketer.\n- Return valid JSON only.\n- Do not wrap the JSON in markdown.\n- Do not include commentary before or after the JSON.\n\nReturn this exact JSON structure:\n\n{\n \"intentProfile\": {\n \"campaignIntent\": \"string\",\n \"targetAudienceInterpretation\": \"string\",\n \"idealCreatorTypes\": [\"string\"],\n \"idealContentThemes\": [\"string\"],\n \"rankingLogic\": \"string\"\n },\n \"rankedCreators\": [\n {\n \"id\": \"creator id\",\n \"scoreRationale\": {\n \"authority\": \"string\",\n \"audienceFit\": \"string\",\n \"engagementQuality\": \"string\",\n \"campaignFit\": \"string\"\n },\n \"matchReason\": \"string\",\n \"riskNote\": \"string\",\n \"recommendedUseCase\": \"string\",\n \"outreachAngle\": \"string\",\n \"confidence\": \"High | Medium | Low\"\n }\n ]\n}\n\nCampaign and creators:\n${JSON.stringify(llmInput, null, 2)}\n`;\n\nreturn [\n {\n json: {\n brief,\n rankedCreators,\n chatInput\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
448,
0
],
"id": "abb7f300-73f5-4c4e-83c3-43ca7dbcced9",
"name": "Build LLM Prompt"
},
{
"parameters": {
"promptType": "define",
"text": "={{ $json.chatInput }}",
"batching": {}
},
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"typeVersion": 1.9,
"position": [
672,
0
],
"id": "a8e17dcb-f0f9-4270-a79f-f144d3b368c8",
"name": "LLM Creator Fit Explanation"
},
{
"parameters": {
"options": {
"maxTokens": 3000
}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
"typeVersion": 1,
"position": [
688,
224
],
"id": "703fefea-419e-4329-ada6-8a36d3305f36",
"name": "OpenRouter Chat Model"
},
{
"parameters": {
"jsCode": "const llmRaw = $input.first().json;\n\n// Get baseline scored creators from the previous prompt-building node.\nconst promptNode = $(\"Build LLM Prompt\").first().json;\n\nconst brief = promptNode.brief ?? {};\nconst baselineCreators = Array.isArray(promptNode.rankedCreators)\n ? promptNode.rankedCreators\n : [];\n\nfunction parsePossibleLLMOutput(value) {\n if (!value) return {};\n\n // If Structured Output Parser worked, value may already be clean JSON.\n if (value.intentProfile || value.rankedCreators) {\n return value;\n }\n\n // Common n8n/LangChain output fields.\n const possible =\n value.output ??\n value.text ??\n value.response ??\n value.result ??\n value.data ??\n value;\n\n if (typeof possible === \"object\") {\n return possible;\n }\n\n if (typeof possible !== \"string\") {\n return {};\n }\n\n const cleaned = possible\n .replace(/^```json\\s*/i, \"\")\n .replace(/^```\\s*/i, \"\")\n .replace(/```$/i, \"\")\n .trim();\n\n try {\n return JSON.parse(cleaned);\n } catch (error) {\n return {};\n }\n}\n\nconst llmParsed = parsePossibleLLMOutput(llmRaw);\n\nconst llmCreators = Array.isArray(llmParsed.rankedCreators)\n ? llmParsed.rankedCreators\n : [];\n\nconst llmById = new Map(\n llmCreators\n .filter((creator) => creator && creator.id)\n .map((creator) => [creator.id, creator])\n);\n\nconst enrichedCreators = baselineCreators.map((creator) => {\n const llm = llmById.get(creator.id) ?? {};\n\n return {\n ...creator,\n\n // Keep deterministic scores stable.\n scores: creator.scores,\n\n // Let LLM enrich explanation fields.\n scoreRationale: llm.scoreRationale ?? {\n authority: \"Baseline authority score generated from profile, niche, and content-theme fit.\",\n audienceFit: \"Baseline audience fit score generated from audience summary, region, language, and target-audience overlap.\",\n engagementQuality: \"Baseline engagement quality score estimated from content depth, post activity, and follower context.\",\n campaignFit: \"Baseline campaign fit score generated from weighted authority, audience fit, engagement quality, and goal relevance.\"\n },\n\n matchReason: llm.matchReason ?? creator.matchReason,\n riskNote: llm.riskNote ?? creator.riskNote,\n recommendedUseCase: llm.recommendedUseCase ?? creator.recommendedUseCase,\n outreachAngle: llm.outreachAngle ?? `Lead with ${creator.name}'s content relevance to the campaign audience.`,\n confidence: llm.confidence ?? \"Medium\"\n };\n});\n\nconst intentProfile = llmParsed.intentProfile ?? {\n campaignIntent: brief.goal || brief.campaignName || \"Creator discovery campaign\",\n targetAudienceInterpretation: brief.targetAudience || \"Professional audience\",\n idealCreatorTypes: brief.creatorType ? [brief.creatorType] : [],\n idealContentThemes: [brief.industry, brief.goal].filter(Boolean),\n rankingLogic: \"Creators were ranked using deterministic scoring, then enriched with LLM-generated explanations.\"\n};\n\nreturn [\n {\n json: {\n workflow: \"credence-score-creators\",\n mode: \"deterministic-scoring-plus-llm-chain\",\n brief,\n intentProfile,\n rankedCreators: enrichedCreators\n }\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1024,
0
],
"id": "2b937ac9-ac65-4a6e-8591-d36823d29c01",
"name": "Merge LLM Output"
}
],
"pinData": {
"Webhook": [
{
"json": {
"brief": {
"campaignName": "AI Productivity Launch",
"brandName": "FlowPilot",
"targetAudience": "founders and product managers",
"industry": "AI SaaS",
"region": "South India",
"language": "English",
"goal": "drive awareness and product trials",
"budgetRange": "₹50,000–₹1,00,000",
"creatorType": "Operator"
},
"creators": [
{
"id": "cr_001",
"name": "Pranav Iyer",
"title": "Product Operator",
"city": "Chennai",
"region": "South India",
"language": "English",
"category": "Operator",
"followers": 24500,
"summary": "Writes about product management, AI workflows, and startup execution.",
"contentThemes": [
"AI workflows",
"product management",
"startup execution"
],
"audienceSummary": "Product managers, startup founders, early-stage operators",
"recentPosts": [
"How AI workflows are changing product execution",
"Why early-stage startups need sharper GTM loops"
]
}
]
}
}
]
},
"connections": {
"Webhook": {
"main": [
[
{
"node": "Score and Rank Creators",
"type": "main",
"index": 0
}
]
]
},
"Score and Rank Creators": {
"main": [
[
{
"node": "Build LLM Prompt",
"type": "main",
"index": 0
}
]
]
},
"Build LLM Prompt": {
"main": [
[
{
"node": "LLM Creator Fit Explanation",
"type": "main",
"index": 0
}
]
]
},
"LLM Creator Fit Explanation": {
"main": [
[
{
"node": "Merge LLM Output",
"type": "main",
"index": 0
}
]
]
},
"OpenRouter Chat Model": {
"ai_languageModel": [
[
{
"node": "LLM Creator Fit Explanation",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Merge LLM Output": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
},
"nodeGroups": [],
"tags": []
}