articipant, message_body, recorded_at
FROM dialogue_log
WHERE conversation_id = $1
ORDER BY recorded_at DESC
LIMIT 20;
### Step 4: Format the Payload
LLM APIs require strictly formatted message arrays. The transformation layer must reverse the descending database results into chronological order, append the new user input, and validate role alternation.
```typescript
// n8n Code Node: Format Context
const historyRows = $input.all().map(row => row.json);
const webhookData = $input.first().json;
const conversationId = webhookData.conversation_id || 'default-session';
const newUserMessage = webhookData.message;
// Restore chronological order
const chronologicalHistory = historyRows.reverse();
// Build Anthropic-compatible message array
const formattedMessages = [
...chronologicalHistory.map(row => ({
role: row.participant,
content: row.message_body
})),
{ role: 'user', content: newUserMessage }
];
return [{
json: {
conversation_id: conversationId,
payload: formattedMessages,
incoming_message: newUserMessage
}
}];
Step 5: Execute Inference
Send the formatted payload to the Claude API. Explicit headers and model selection ensure compatibility and predictable behavior.
{
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"headers": {
"x-api-key": "={{ $env.ANTHROPIC_API_KEY }}",
"anthropic-version": "2023-06-01",
"content-type": "application/json"
},
"body": {
"model": "claude-3-5-haiku-20241022",
"max_tokens": 1024,
"system": "You are a context-aware assistant. Reference previous exchanges when relevant. Maintain a concise, professional tone.",
"messages": "={{ $json.payload }}"
}
}
Step 6: Persist the Exchange
Extract the assistant's response and write both the user input and AI output to the database in a single transactional operation.
INSERT INTO dialogue_log (conversation_id, participant, message_body, recorded_at)
VALUES
($1, 'user', $2, NOW()),
($1, 'assistant', $3, NOW());
Step 7: Return the Response
Serialize the assistant's output and deliver it back through the webhook response node. The pipeline completes with full state persistence.
Architecture Decisions:
- Postgres over Redis: Relational storage provides durable history, ACID compliance, and native indexing. Redis requires manual TTL management and lacks chronological query optimization.
- HTTP Request over Native Nodes: Explicit HTTP configuration grants full control over payload structure, headers, and error handling. It avoids vendor lock-in and simplifies provider swaps.
- Sliding Window Retrieval: Fetching the last N turns balances context richness with cost efficiency. It prevents exponential token growth while preserving immediate conversational continuity.
Pitfall Guide
1. SQL Injection via String Interpolation
Explanation: Constructing SQL queries by concatenating user input directly into the query string exposes the database to injection attacks. n8n's Postgres node supports parameterized queries, but developers often bypass them for convenience.
Fix: Use $1, $2 placeholders and pass parameters through the node's parameter mapping interface. Never embed {{ $json.variable }} directly inside SQL strings.
2. Unbounded Context Growth
Explanation: Removing the LIMIT clause or setting it excessively high causes token counts to spike, triggering API errors and inflating costs. Claude's context window is finite; exceeding it results in silent truncation or explicit rejection.
Fix: Implement a sliding window (e.g., 20 turns) and add a token counter node before the API call. If the payload exceeds 80% of the model's context limit, truncate the oldest messages or trigger a summarization routine.
3. Missing Composite Index
Explanation: Querying by conversation_id without an index on recorded_at forces sequential scans. Performance degrades linearly as conversation history grows, causing webhook timeouts.
Fix: Always create a composite index: CREATE INDEX ON dialogue_log(conversation_id, recorded_at DESC). Verify query plans using EXPLAIN ANALYZE during load testing.
4. Invalid Role Alternation
Explanation: Anthropic's API requires strict user/assistant alternation. Duplicate roles or missing turns cause 400 Bad Request errors. This commonly occurs when error handling fails to persist both sides of an exchange.
Fix: Validate the message array before sending. Ensure every user entry has a corresponding assistant entry. Add a Code node that checks payload[i].role !== payload[i+1].role and repairs sequences if necessary.
5. Race Conditions on Concurrent Writes
Explanation: Multiple simultaneous messages from the same conversation can cause out-of-order inserts or duplicate entries. Webhook handlers process requests asynchronously, increasing collision probability.
Fix: Implement advisory locks in Postgres (pg_advisory_xact_lock) or route messages through a queue (e.g., RabbitMQ, BullMQ) to serialize writes per conversation_id. Alternatively, use ON CONFLICT clauses with unique constraints.
6. Hardcoded Credentials
Explanation: Embedding API keys directly in workflow JSON or environment files violates security best practices and complicates rotation.
Fix: Store credentials in n8n's encrypted credential manager or use environment variables ($env.ANTHROPIC_API_KEY). Restrict key permissions to message-only scopes and enable audit logging.
7. Silent API Failures
Explanation: Network timeouts, rate limits, or malformed payloads can cause the workflow to hang or return incomplete data without alerting operators.
Fix: Configure retry policies on the HTTP Request node (3 attempts, exponential backoff). Add error handling branches that log failures to a monitoring service (e.g., Sentry, Datadog) and return graceful fallback responses.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Short-term customer support (< 30 turns) | Relational sliding window | Deterministic ordering, zero embedding cost, fast retrieval | Low ($0.00 storage, predictable API costs) |
| Long-term knowledge retention (> 100 turns) | Relational + periodic summarization | Prevents context overflow while preserving key facts | Medium (summarization API calls + storage) |
| Multi-modal or semantic search requirements | Vector database (pgvector) | Enables similarity matching across unstructured data | High (embedding generation, index maintenance) |
| High-concurrency real-time chat | Relational + message queue | Serializes writes, prevents race conditions | Medium (queue infrastructure + latency overhead) |
Configuration Template
Environment Variables:
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxx
DB_HOST=your-supabase-instance.supabase.co
DB_PORT=5432
DB_NAME=postgres
DB_USER=postgres
DB_PASSWORD=your-secure-password
n8n Workflow JSON (Abbreviated Production Structure):
{
"name": "Stateful AI Dialogue Pipeline",
"nodes": [
{
"parameters": { "path": "ai-dialogue", "responseMode": "responseNode" },
"id": "trigger-webhook",
"name": "Inbound Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [200, 300]
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT participant, message_body, recorded_at FROM dialogue_log WHERE conversation_id = $1 ORDER BY recorded_at DESC LIMIT 20",
"options": {}
},
"id": "fetch-history",
"name": "Retrieve Context",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [450, 300]
},
{
"parameters": {
"jsCode": "const rows = $input.all().map(r => r.json);\nconst ctx = $input.first().json;\nconst convId = ctx.conversation_id || 'default';\nconst msg = ctx.message;\nconst ordered = rows.reverse();\nconst payload = [\n ...ordered.map(r => ({ role: r.participant, content: r.message_body })),\n { role: 'user', content: msg }\n];\nreturn [{ json: { conversation_id: convId, payload, incoming: msg } }];"
},
"id": "format-payload",
"name": "Structure Messages",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [700, 300]
},
{
"parameters": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "x-api-key", "value": "={{ $env.ANTHROPIC_API_KEY }}" },
{ "name": "anthropic-version", "value": "2023-06-01" },
{ "name": "content-type", "value": "application/json" }
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{ "name": "model", "value": "claude-3-5-haiku-20241022" },
{ "name": "max_tokens", "value": "1024" },
{ "name": "system", "value": "You are a context-aware assistant. Reference previous exchanges when relevant." },
{ "name": "messages", "value": "={{ $json.payload }}" }
]
}
},
"id": "call-claude",
"name": "Execute Inference",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [950, 300]
},
{
"parameters": {
"jsCode": "const resp = $input.first().json;\nconst ctx = $('Structure Messages').first().json;\nconst reply = resp.content[0].text;\nreturn [{ json: { conversation_id: ctx.conversation_id, incoming: ctx.incoming, reply } }];"
},
"id": "parse-response",
"name": "Extract Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1200, 300]
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO dialogue_log (conversation_id, participant, message_body, recorded_at) VALUES ($1, 'user', $2, NOW()), ($1, 'assistant', $3, NOW());",
"options": {}
},
"id": "persist-turn",
"name": "Save Exchange",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.5,
"position": [1450, 300]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { reply: $('Extract Output').first().json.reply } }}"
},
"id": "send-response",
"name": "Deliver Result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1700, 300]
}
],
"connections": {
"Inbound Webhook": { "main": [[{ "node": "Retrieve Context", "type": "main", "index": 0 }]] },
"Retrieve Context": { "main": [[{ "node": "Structure Messages", "type": "main", "index": 0 }]] },
"Structure Messages": { "main": [[{ "node": "Execute Inference", "type": "main", "index": 0 }]] },
"Execute Inference": { "main": [[{ "node": "Extract Output", "type": "main", "index": 0 }]] },
"Extract Output": { "main": [[{ "node": "Save Exchange", "type": "main", "index": 0 }]] },
"Save Exchange": { "main": [[{ "node": "Deliver Result", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" }
}
Quick Start Guide
- Provision Storage: Run the
dialogue_log DDL script in your Postgres or Supabase SQL editor. Verify the composite index exists.
- Configure Credentials: Add
ANTHROPIC_API_KEY to n8n's environment variables. Set up a Postgres credential using your database connection string.
- Import Workflow: Copy the JSON template into n8n via Workflows β Import from Clipboard. Map the Postgres credential to the database nodes.
- Activate & Test: Enable the workflow. Send a test payload:
curl -X POST https://your-n8n-instance.com/webhook/ai-dialogue \
-H "Content-Type: application/json" \
-d '{"conversation_id": "test-001", "message": "Initialize session and remember I prefer metric units."}'
Follow with a second request referencing the stored preference to verify state persistence. Monitor logs for latency and token usage.