This workflow follows the HTTP Request → Postgres 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": "9c7e0a12-6d2f-4f9c-9ab1-cf2d6c8f5303",
"name": "agent_query_main",
"active": true,
"settings": {
"executionOrder": "v1"
},
"nodes": [
{
"id": "WebhookAgentQuery",
"name": "webhook_agent_query",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
220,
340
],
"parameters": {
"httpMethod": "POST",
"path": "agent-query",
"responseMode": "lastNode",
"options": {}
}
},
{
"id": "NormalizeInput",
"name": "Normalize Input",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
340
],
"parameters": {
"jsCode": "const body = $json.body ?? $json;\nconst question = String(body.question ?? body.message ?? '').trim();\nif (!question) throw new Error('question is required');\nconst sessionId = String(body.session_id ?? body.user_id ?? 'default').trim();\nconst userId = String(body.user_id ?? 'anonymous').trim();\nconst token = $env.AGENT_WORKFLOW_TOKEN || '';\nconst traceId = String(body.trace_id ?? `${sessionId}-${Date.now()}`).trim();\nreturn [{\n json: {\n session_id: sessionId,\n user_id: userId,\n question,\n token,\n trace_id: traceId\n }\n}];"
}
},
{
"id": "WriteUserMessage",
"name": "HTTP: memory_write user",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
700,
340
],
"continueOnFail": true,
"parameters": {
"method": "POST",
"url": "={{ (($env.INTERNAL_WEBHOOK_BASE_URL || 'http://127.0.0.1:5678').replace(/\\/?$/, '')) + '/webhook/' + ($env.N8N_MEMORY_WRITE_WEBHOOK_PATH || 'agent-memory-write') }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { session_id: $json.session_id, role: 'user', message: $json.question, metadata: { source: 'agent_query_main', user_id: $json.user_id }, token: $json.token } }}",
"options": {}
}
},
{
"id": "BuildWikiQuery",
"name": "Build Wiki Query",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
940,
340
],
"parameters": {
"jsCode": "const normalized = $node['Normalize Input'].json;\nconst question = String(normalized.question ?? '').trim();\nconst questionLower = question.toLowerCase().replace(/\u0451/g, '\u0435');\nconst normalizedQuestion = questionLower\n .replace(/[^\\p{L}\\p{N}\\s-]+/gu, ' ')\n .replace(/\u0434\u043e\u043a\u0435\u0440\u0430/g, 'docker')\n .replace(/\u0434\u043e\u043a\u0435\u0440/g, 'docker')\n .replace(/\u043a\u043e\u043d\u0442\u0435\u043d\u0435\u0440/g, '\u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440')\n .replace(/[_-]+/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\nconst stopWords = new Set([\n '\u0447\u0442\u043e', '\u044d\u0442\u043e', '\u043a\u0430\u043a', '\u0434\u043b\u044f', '\u0438\u043b\u0438', '\u043f\u0440\u043e', '\u043d\u0430\u0434', '\u043f\u043e\u0434', '\u0442\u0430\u043a\u043e\u0435', '\u0442\u0430\u043a\u043e\u0439',\n '\u043a\u0430\u043a\u0430\u044f', '\u043a\u0430\u043a\u043e\u0435', '\u043a\u0442\u043e', '\u0437\u0430\u0447\u0435\u043c', '\u043f\u043e\u0447\u0435\u043c\u0443', '\u0440\u0430\u0441\u0441\u043a\u0430\u0436\u0438', '\u043e\u0431\u044a\u044f\u0441\u043d\u0438',\n 'the', 'and', 'with', 'from', 'into', 'about', 'vs'\n]);\nconst terms = normalizedQuestion\n .split(' ')\n .map((term) => term.trim())\n .filter((term) => term.length >= 2 && !stopWords.has(term))\n .slice(0, 16);\nconst fallbackTerms = normalizedQuestion\n .split(' ')\n .map((term) => term.trim())\n .filter((term) => term.length >= 3)\n .slice(0, 10);\nconst searchTerms = terms.length > 0 ? terms : fallbackTerms;\nconst questionPlain = normalizedQuestion || questionLower;\nconst tsText = searchTerms.length > 0 ? searchTerms.join(' ') : questionPlain;\nconst questionPlainSql = questionPlain.replace(/'/g, \"''\");\nconst questionTermsSql = searchTerms.join(' ').replace(/'/g, \"''\");\nconst questionTsSql = tsText.replace(/'/g, \"''\");\nconst limitRaw = Number($env.AGENT_WIKI_LIMIT ?? 5);\nconst limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(20, Math.trunc(limitRaw))) : 5;\nconst maxContextCharsRaw = Number($env.AGENT_WIKI_CONTEXT_CHARS ?? 8000);\nconst maxContextChars = Number.isFinite(maxContextCharsRaw) ? Math.max(1000, Math.min(50000, Math.trunc(maxContextCharsRaw))) : 8000;\nconst identRe = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;\nconst wikiSchema = String($env.AGENT_WIKI_SCHEMA || 'public').trim();\nconst wikiTable = String($env.AGENT_WIKI_TABLE || 'pages').trim();\nconst wikiTitleColumn = String($env.AGENT_WIKI_TITLE_COLUMN || 'title').trim();\nconst wikiPathColumn = String($env.AGENT_WIKI_PATH_COLUMN || 'path').trim();\nconst wikiContentColumn = String($env.AGENT_WIKI_CONTENT_COLUMN || 'content').trim();\nfor (const [name, value] of Object.entries({ wikiSchema, wikiTable, wikiTitleColumn, wikiPathColumn, wikiContentColumn })) {\n if (!identRe.test(value)) {\n throw new Error('invalid ' + name);\n }\n}\nreturn [{\n json: {\n ...normalized,\n question_plain_sql: questionPlainSql,\n question_terms_sql: questionTermsSql,\n question_ts_sql: questionTsSql,\n question_terms_count: searchTerms.length,\n limit,\n max_context_chars: maxContextChars,\n wiki_schema_sql: wikiSchema,\n wiki_table_sql: wikiTable,\n wiki_title_column_sql: wikiTitleColumn,\n wiki_path_column_sql: wikiPathColumn,\n wiki_content_column_sql: wikiContentColumn\n }\n}];"
}
},
{
"id": "ReadWikiContext",
"name": "Postgres: Read Wiki Context",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
1180,
340
],
"continueOnFail": true,
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"parameters": {
"operation": "executeQuery",
"query": "WITH params AS (\n SELECT\n LOWER('{{$json.question_plain_sql}}') AS q,\n NULLIF(TRIM('{{$json.question_terms_sql}}'), '') AS terms,\n plainto_tsquery('russian', '{{$json.question_ts_sql}}') AS q_ru,\n plainto_tsquery('simple', '{{$json.question_ts_sql}}') AS q_simple\n),\nsource_rows AS (\n SELECT\n COALESCE(to_jsonb(t) ->> '{{$json.wiki_title_column_sql}}', '') AS title,\n COALESCE(to_jsonb(t) ->> '{{$json.wiki_path_column_sql}}', '') AS path,\n COALESCE(to_jsonb(t) ->> '{{$json.wiki_content_column_sql}}', '') AS content\n FROM {{$json.wiki_schema_sql}}.{{$json.wiki_table_sql}} t\n WHERE COALESCE(to_jsonb(t) ->> '{{$json.wiki_content_column_sql}}', '') <> ''\n),\nranked AS (\n SELECT\n s.title,\n s.path,\n s.content,\n ts_rank_cd(\n setweight(to_tsvector('russian', s.title), 'A') ||\n setweight(to_tsvector('russian', s.content), 'B'),\n p.q_ru\n ) AS fts_rank,\n ts_rank_cd(to_tsvector('simple', s.path), p.q_simple) AS path_rank,\n CASE\n WHEN s.title <> '' AND LOWER(s.title) LIKE '%' || p.q || '%' THEN 3\n WHEN s.path <> '' AND LOWER(s.path) LIKE '%' || p.q || '%' THEN 2\n WHEN LOWER(s.content) LIKE '%' || p.q || '%' THEN 1\n ELSE 0\n END AS exact_rank,\n COALESCE((\n SELECT COUNT(*)\n FROM UNNEST(string_to_array(p.terms, ' ')) AS term\n WHERE length(term) >= 2\n AND (\n LOWER(s.title) LIKE '%' || term || '%'\n OR LOWER(s.path) LIKE '%' || term || '%'\n OR LOWER(s.content) LIKE '%' || term || '%'\n OR LOWER(s.title) LIKE '%' || LEFT(term, 4) || '%'\n OR LOWER(s.path) LIKE '%' || LEFT(term, 4) || '%'\n OR LOWER(s.content) LIKE '%' || LEFT(term, 4) || '%'\n )\n ), 0) AS term_hits\n FROM source_rows s\n CROSS JOIN params p\n),\nmatched AS (\n SELECT\n title,\n path,\n content,\n term_hits,\n (fts_rank * 10.0 + path_rank * 7.0 + exact_rank * 6.0 + term_hits * 2.0)::numeric AS score\n FROM ranked\n WHERE term_hits > 0 OR fts_rank > 0 OR path_rank > 0 OR exact_rank > 0\n ORDER BY score DESC, term_hits DESC, LENGTH(content) DESC\n LIMIT {{$json.limit}}\n)\nSELECT title, path, content, score, term_hits\nFROM matched\nUNION ALL\nSELECT '' AS title, '' AS path, '' AS content, 0::numeric AS score, 0::int AS term_hits\nWHERE NOT EXISTS (SELECT 1 FROM matched);"
}
},
{
"id": "ShapeWikiContext",
"name": "Build Wiki Context",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1420,
340
],
"parameters": {
"jsCode": "const maxChars = Number($node['Build Wiki Query'].json.max_context_chars ?? 8000);\nconst rows = items.map((item) => item.json);\nlet used = 0;\nlet topScore = 0;\nlet topTermHits = 0;\nconst blocks = [];\nconst sources = [];\nfor (const row of rows) {\n const title = String(row.title ?? '').trim();\n const path = String(row.path ?? '').trim();\n const content = String(row.content ?? '').replace(/\\s+/g, ' ').trim();\n const score = Number(row.score ?? 0);\n const termHits = Number(row.term_hits ?? 0);\n if (Number.isFinite(score) && score > topScore) topScore = score;\n if (Number.isFinite(termHits) && termHits > topTermHits) topTermHits = termHits;\n if (!content) continue;\n const snippet = content.length > 1600 ? `${content.slice(0, 1600)}\u2026` : content;\n const docId = `D${sources.length + 1}`;\n sources.push({ id: docId, title, path, content: snippet, score, term_hits: termHits });\n const block = [\n `[${docId}]`,\n title ? `TITLE: ${title}` : null,\n path ? `PATH: ${path}` : null,\n `CONTENT: ${snippet}`\n ].filter(Boolean).join('\\n');\n const nextUsed = used + block.length + (blocks.length > 0 ? 6 : 0);\n if (nextUsed > maxChars && blocks.length > 0) break;\n blocks.push(block);\n used = nextUsed;\n}\nconst contextText = blocks.join('\\n\\n---\\n\\n');\nreturn [{\n json: {\n count: blocks.length,\n memory_text: contextText,\n context_source: 'wiki',\n top_score: topScore,\n top_term_hits: topTermHits,\n sources\n }\n}];"
}
},
{
"id": "BuildPrompt",
"name": "Build Prompt",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1660,
340
],
"parameters": {
"jsCode": "const normalized = $node['Normalize Input'].json;\nconst queryMeta = $node['Build Wiki Query'].json;\nconst memory = String($json.memory_text ?? '').trim();\nconst memoryCount = Number($json.count ?? 0);\nconst topScore = Number($json.top_score ?? 0);\nconst topTermHits = Number($json.top_term_hits ?? 0);\nconst questionTermsCount = Number(queryMeta.question_terms_count ?? 0);\nconst sources = Array.isArray($json.sources) ? $json.sources : [];\nconst minMemoryCharsRaw = Number($env.AGENT_MIN_MEMORY_CHARS ?? 40);\nconst minMemoryChars = Number.isFinite(minMemoryCharsRaw) ? Math.max(0, Math.trunc(minMemoryCharsRaw)) : 40;\nconst minWikiScoreRaw = Number($env.AGENT_WIKI_MIN_SCORE ?? 2);\nconst minWikiScore = Number.isFinite(minWikiScoreRaw) ? Math.max(0, minWikiScoreRaw) : 2;\nconst minTermHitsRaw = Number($env.AGENT_WIKI_MIN_TERM_HITS ?? 1);\nconst minTermHits = Number.isFinite(minTermHitsRaw) ? Math.max(0, minTermHitsRaw) : 1;\nconst memoryHasContext = memoryCount > 0 && memory.length >= minMemoryChars && topScore >= minWikiScore && topTermHits >= minTermHits;\nconst questionLower = String(normalized.question ?? '').toLowerCase().replace(/\u0451/g, '\u0435');\nconst greetingPatterns = [/^\u043f\u0440\u0438\u0432\u0435\u0442\\b/, /^\u0437\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439/, /^\u0434\u043e\u0431\u0440\u044b\u0439\\s+(\u0434\u0435\u043d\u044c|\u0432\u0435\u0447\u0435\u0440|\u0443\u0442\u0440\u043e)\\b/, /^hello\\b/, /^hi\\b/];\nconst isGreetingQuestion = greetingPatterns.some((re) => re.test(questionLower));\nconst identityPatterns = [/\u043a\u0442\u043e\\s+\u0442\u044b/, /\u0442\u044b\\s+\u043a\u0442\u043e/, /who\\s+are\\s+you/];\nconst isIdentityQuestion = identityPatterns.some((re) => re.test(questionLower));\nconst identityAnswer = '\u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.';\nconst greetingAnswer = '\u041f\u0440\u0438\u0432\u0435\u0442! \u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.';\nconst metaQuestionPatterns = [\n /\u0442\u0432\u043e\u044f \u0440\u043e\u043b\u044c/,\n /\u0441\u0438\u0441\u0442\u0435\u043c\u043d(\u044b\u0439|\u043e\u0433\u043e)? \u043f\u0440\u043e\u043c\u043f\u0442/,\n /\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446/,\n /\u0447\u0442\u043e \u0442\u044b \u0443\u043c\u0435\u0435\u0448\u044c/,\n /\u0447\u0443\u0432\u0441\u0442\u0432/,\n /\u043f\u0430\u043c\u044f\u0442[\u044c\u0438]/,\n /session/\n];\nconst isMetaQuestion = metaQuestionPatterns.some((re) => re.test(questionLower));\nconst defaultSystemPrompt = [\n '\u0422\u044b \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.',\n '\u0412\u0441\u0435\u0433\u0434\u0430 \u043e\u0442\u0432\u0435\u0447\u0430\u0439 \u043d\u0430 \u0440\u0443\u0441\u0441\u043a\u043e\u043c \u044f\u0437\u044b\u043a\u0435.',\n '\u041e\u0442\u0432\u0435\u0447\u0430\u0439 \u0442\u043e\u043b\u044c\u043a\u043e \u043d\u0430 \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u0438\u0438 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u043e\u0432 \u0438\u0437 CONTEXT.',\n '\u041d\u0435\u043b\u044c\u0437\u044f \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u0442\u044c \u0444\u0430\u043a\u0442\u044b, \u043a\u043e\u0442\u043e\u0440\u044b\u0445 \u043d\u0435\u0442 \u0432 \u0438\u0441\u0442\u043e\u0447\u043d\u0438\u043a\u0430\u0445.',\n '\u0412\u0435\u0440\u043d\u0438 \u0441\u0442\u0440\u043e\u0433\u043e JSON \u0431\u0435\u0437 markdown \u0438 \u0431\u0435\u0437 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u0442\u0435\u043a\u0441\u0442\u0430.',\n '\u0424\u043e\u0440\u043c\u0430\u0442: {\"answer\":\"...\",\"citations\":[\"D1\"],\"enough_context\":true}',\n '\u0415\u0441\u043b\u0438 \u0434\u0430\u043d\u043d\u044b\u0445 \u043d\u0435\u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e: {\"answer\":\"\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.\",\"citations\":[],\"enough_context\":false}'\n].join(' ');\nconst systemPrompt = String($env.AGENT_SYSTEM_PROMPT || defaultSystemPrompt).trim();\nconst ollamaModel = String($env.OLLAMA_MODEL || '').trim();\nif (!ollamaModel) throw new Error('OLLAMA_MODEL is required');\nconst prompt = [\n systemPrompt,\n '',\n 'CONTEXT:',\n memoryHasContext ? memory : '[empty]',\n '',\n 'SOURCES_JSON:',\n JSON.stringify(sources),\n '',\n `QUESTION: ${normalized.question}`,\n '',\n '\u041e\u0442\u0432\u0435\u0442 \u0432\u0435\u0440\u043d\u0438 \u0442\u043e\u043b\u044c\u043a\u043e JSON-\u043e\u0431\u044a\u0435\u043a\u0442\u043e\u043c.'\n].join('\\n');\nreturn [{\n json: {\n session_id: normalized.session_id,\n user_id: normalized.user_id,\n question: normalized.question,\n token: normalized.token,\n trace_id: normalized.trace_id,\n memory_count: memoryCount,\n memory_has_context: memoryHasContext,\n top_score: topScore,\n top_term_hits: topTermHits,\n question_terms_count: questionTermsCount,\n min_wiki_score: minWikiScore,\n min_term_hits: minTermHits,\n is_identity_question: isIdentityQuestion,\n is_greeting_question: isGreetingQuestion,\n identity_answer: identityAnswer,\n greeting_answer: greetingAnswer,\n is_meta_question: isMetaQuestion,\n ollama_model: ollamaModel,\n prompt,\n sources\n }\n}];"
}
},
{
"id": "CallOllama",
"name": "HTTP: Ollama Generate",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1900,
340
],
"continueOnFail": true,
"parameters": {
"method": "POST",
"url": "={{ ($env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434') + '/api/generate' }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { model: $json.ollama_model, prompt: $json.prompt, stream: false, format: 'json', options: { temperature: 0.1 } } }}",
"options": {
"timeout": 120000
}
}
},
{
"id": "NormalizeAnswer",
"name": "Normalize Answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2140,
340
],
"parameters": {
"jsCode": "const base = $node['Build Prompt'].json;\nconst allowGeneralKb = String($env.AGENT_ALLOW_GENERAL_KB || 'false').toLowerCase() === 'true';\nconst minOverlapRaw = Number($env.AGENT_MIN_ANSWER_OVERLAP ?? 0);\nconst minOverlap = Number.isFinite(minOverlapRaw) ? Math.max(0, Math.trunc(minOverlapRaw)) : 0;\nlet answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\nlet guardReason = 'ok';\nlet citations = [];\nlet overlapCount = 0;\nif (base.is_greeting_question) {\n answer = String(base.greeting_answer || '\u041f\u0440\u0438\u0432\u0435\u0442! \u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.');\n guardReason = 'greeting_fixed';\n} else if (base.is_identity_question) {\n answer = String(base.identity_answer || '\u042f \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0438\u0439 \u0418\u0418 \u0430\u0441\u0441\u0438\u0441\u0442\u0435\u043d\u0442.');\n guardReason = 'identity_fixed';\n} else if (!allowGeneralKb && base.is_meta_question) {\n answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n guardReason = 'meta_question_blocked';\n} else if (!allowGeneralKb && !base.memory_has_context) {\n answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n guardReason = 'no_context';\n} else {\n const raw = String($json.response ?? '').trim();\n const tryParseJson = (input) => {\n if (!input) return null;\n try {\n return JSON.parse(input);\n } catch {\n return null;\n }\n };\n const extractJsonObject = (input) => {\n if (!input) return '';\n const cleaned = input\n .replace(/^```json\\s*/i, '')\n .replace(/^```\\s*/i, '')\n .replace(/\\s*```$/i, '')\n .trim();\n if (!cleaned) return '';\n let depth = 0;\n let start = -1;\n let inString = false;\n let escaped = false;\n for (let i = 0; i < cleaned.length; i += 1) {\n const ch = cleaned[i];\n if (inString) {\n if (escaped) {\n escaped = false;\n } else if (ch === '\\\\') {\n escaped = true;\n } else if (ch === '\"') {\n inString = false;\n }\n continue;\n }\n if (ch === '\"') {\n inString = true;\n continue;\n }\n if (ch === '{') {\n if (depth === 0) start = i;\n depth += 1;\n } else if (ch === '}') {\n if (depth > 0) depth -= 1;\n if (depth === 0 && start !== -1) {\n return cleaned.slice(start, i + 1);\n }\n }\n }\n return '';\n };\n let parsed = tryParseJson(raw);\n if (!parsed || typeof parsed !== 'object') {\n const extracted = extractJsonObject(raw);\n parsed = tryParseJson(extracted);\n }\n if (!parsed || typeof parsed !== 'object') {\n answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n guardReason = 'model_non_json';\n } else {\n const parsedAnswer = String(parsed.answer ?? '').trim();\n const enoughContext = Boolean(parsed.enough_context);\n let parsedCitations = Array.isArray(parsed.citations)\n ? parsed.citations.map((v) => String(v || '').trim()).filter(Boolean)\n : [];\n if (!enoughContext || !parsedAnswer || parsedAnswer === '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.') {\n answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n guardReason = 'insufficient_context';\n } else {\n const sourceList = Array.isArray(base.sources) ? base.sources : [];\n const sourceMap = new Map(sourceList.map((s) => [String(s.id), String(s.content ?? '').toLowerCase()]));\n if (parsedCitations.length === 0 && sourceList.length > 0) {\n parsedCitations = [String(sourceList[0].id)];\n }\n const allValidCitations = parsedCitations.length > 0 && parsedCitations.every((id) => sourceMap.has(id));\n if (!allValidCitations) {\n answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n guardReason = 'invalid_citations';\n } else {\n const citedText = parsedCitations.map((id) => sourceMap.get(id) || '').join(' ');\n const tokenSet = new Set(\n parsedAnswer\n .toLowerCase()\n .replace(/[^\\p{L}\\p{N}\\s-]+/gu, ' ')\n .split(/\\s+/)\n .map((t) => t.trim())\n .filter((t) => t.length >= 4)\n );\n overlapCount = 0;\n for (const token of tokenSet) {\n if (citedText.includes(token)) overlapCount += 1;\n }\n if (minOverlap > 0 && overlapCount < minOverlap) {\n answer = '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.';\n guardReason = 'evidence_mismatch';\n } else {\n answer = parsedAnswer;\n citations = parsedCitations;\n guardReason = 'ok';\n }\n }\n }\n }\n}\nconst shouldPersistAssistant = answer !== '\u041d\u0435\u0442 \u0434\u0430\u043d\u043d\u044b\u0445.' && (guardReason === 'ok' || guardReason === 'identity_fixed' || guardReason === 'greeting_fixed');\nreturn [{\n json: {\n session_id: base.session_id,\n user_id: base.user_id,\n question: base.question,\n answer,\n citations,\n token: base.token,\n trace_id: base.trace_id,\n memory_count: base.memory_count,\n memory_has_context: base.memory_has_context,\n top_score: base.top_score,\n top_term_hits: base.top_term_hits,\n question_terms_count: base.question_terms_count,\n min_wiki_score: base.min_wiki_score,\n min_term_hits: base.min_term_hits,\n is_identity_question: base.is_identity_question,\n is_greeting_question: base.is_greeting_question,\n is_meta_question: base.is_meta_question,\n ollama_model: base.ollama_model,\n guard_reason: guardReason,\n overlap_count: overlapCount,\n should_persist_assistant: shouldPersistAssistant,\n prompt: base.prompt\n }\n}];"
}
},
{
"id": "ShouldPersistAssistant",
"name": "IF: persist assistant",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2360,
340
],
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.should_persist_assistant }}",
"operation": "isTrue"
}
]
},
"options": {}
}
},
{
"id": "WriteAssistantMessage",
"name": "HTTP: memory_write assistant",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2620,
340
],
"continueOnFail": true,
"parameters": {
"method": "POST",
"url": "={{ (($env.INTERNAL_WEBHOOK_BASE_URL || 'http://127.0.0.1:5678').replace(/\\/?$/, '')) + '/webhook/' + ($env.N8N_MEMORY_WRITE_WEBHOOK_PATH || 'agent-memory-write') }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { session_id: $json.session_id, role: 'assistant', message: $json.answer, metadata: { source: 'agent_query_main', model: $json.ollama_model, trace_id: $json.trace_id, guard_reason: $json.guard_reason, memory_count: Number($json.memory_count ?? 0) }, token: $json.token } }}",
"options": {}
}
},
{
"id": "BuildResponse",
"name": "Build API Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2860,
340
],
"parameters": {
"jsCode": "const base = $node['Normalize Answer'].json;\nconst debugEnabled = String($env.AGENT_DEBUG || 'false').toLowerCase() === 'true';\nconst response = {\n ok: true,\n trace_id: base.trace_id,\n session_id: base.session_id,\n user_id: base.user_id,\n question: base.question,\n answer: base.answer,\n citations: Array.isArray(base.citations) ? base.citations : []\n};\nif (debugEnabled) {\n response.debug = {\n memory_count: Number(base.memory_count ?? 0),\n memory_has_context: Boolean(base.memory_has_context),\n top_score: Number(base.top_score ?? 0),\n top_term_hits: Number(base.top_term_hits ?? 0),\n question_terms_count: Number(base.question_terms_count ?? 0),\n min_wiki_score: Number(base.min_wiki_score ?? 0),\n min_term_hits: Number(base.min_term_hits ?? 0),\n is_identity_question: Boolean(base.is_identity_question),\n is_meta_question: Boolean(base.is_meta_question),\n guard_reason: String(base.guard_reason ?? 'unknown'),\n overlap_count: Number(base.overlap_count ?? 0),\n prompt_preview: String(base.prompt ?? '').slice(0, 600)\n };\n}\nreturn [{ json: response }];"
}
}
],
"connections": {
"webhook_agent_query": {
"main": [
[
{
"node": "Normalize Input",
"type": "main",
"index": 0
}
]
]
},
"Normalize Input": {
"main": [
[
{
"node": "HTTP: memory_write user",
"type": "main",
"index": 0
}
]
]
},
"HTTP: memory_write user": {
"main": [
[
{
"node": "Build Wiki Query",
"type": "main",
"index": 0
}
]
]
},
"Build Wiki Query": {
"main": [
[
{
"node": "Postgres: Read Wiki Context",
"type": "main",
"index": 0
}
]
]
},
"Postgres: Read Wiki Context": {
"main": [
[
{
"node": "Build Wiki Context",
"type": "main",
"index": 0
}
]
]
},
"Build Wiki Context": {
"main": [
[
{
"node": "Build Prompt",
"type": "main",
"index": 0
}
]
]
},
"Build Prompt": {
"main": [
[
{
"node": "HTTP: Ollama Generate",
"type": "main",
"index": 0
}
]
]
},
"HTTP: Ollama Generate": {
"main": [
[
{
"node": "Normalize Answer",
"type": "main",
"index": 0
}
]
]
},
"Normalize Answer": {
"main": [
[
{
"node": "IF: persist assistant",
"type": "main",
"index": 0
}
]
]
},
"IF: persist assistant": {
"main": [
[
{
"node": "HTTP: memory_write assistant",
"type": "main",
"index": 0
}
],
[
{
"node": "Build API Response",
"type": "main",
"index": 0
}
]
]
},
"HTTP: memory_write assistant": {
"main": [
[
{
"node": "Build API Response",
"type": "main",
"index": 0
}
]
]
}
},
"versionId": "00000000-0000-0000-0000-000000000003",
"meta": {
"templateCredsSetupCompleted": false
},
"tags": [
{
"name": "agent"
},
{
"name": "query"
}
]
}
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.
postgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
agent_query_main. Uses httpRequest, postgres. Webhook trigger; 12 nodes.
Source: https://github.com/Andrey787878/ai-knowledge-assistant/blob/main/n8n/workflows/agent_query_main.json — 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.
Jigsaw API key for image processing, I use this as a gatekeeper/second pair of eyes. LINK to their website https://jigsawstack.com/ SECOND A postgress DATABASE (I use Supabase) LlamaCloud for the pars
W1 - IN WhatsApp Adapter (Secure + Fast ACK). Uses postgres, redis, httpRequest. Webhook trigger; 48 nodes.
W2 - IN Instagram Adapter (Secure). Uses postgres, httpRequest. Webhook trigger; 28 nodes.
W3 - IN Messenger Adapter (Secure). Uses postgres, httpRequest. Webhook trigger; 28 nodes.
Engagement Tracking Workflow. Uses postgres, httpRequest. Webhook trigger; 22 nodes.