This article is short. It does not explain the architecture or build a narrative. It is a reference guide for a developer building an AI agent in n8n who does not want to spend 4–6 hours on each of these bugs (as I did).

All five come from the real debugging journey of the Puramur sales agent: 39 nodes, a semantic router, an 8-stage state machine, and LOAD → PROCESS → SAVE cycles with Postgres on every message. The symptoms, reproductions, and fixes were verified in a production workflow.

The goal: scan five cards in 15 minutes, lock the patterns into memory, and recognize them in two minutes instead of two hours the next time you debug.

Bug #1 — AI Agent does not pass input downstream

Bug 01

AI Agent output does not contain the previous input

Symptom

You passed state into the AI Agent, it ran and generated a response. In the next node (Extract State Update code), you access $json.state.stage — undefined. In $json contains only the output field (the LLM text) and nothing else.

Repro

Load Session State → Build Prompt → AI Agent → Extract State Update.
In Extract State Update, you write:

const state = $json.state; // undefined const customer = $json.state.customer; // TypeError const llmOutput = $json.output; // works
Cause

The AI Agent node in n8n is not a transparent proxy. It does not pass input.json downstream — its output is only what the LLM generated (+ intermediate steps and tool results, depending on configuration). All previous fields from the input pipeline “disappear” at this node.

This behavior is “by design” but counterintuitive — most other nodes (Code, Set, HTTP Request) either pass input downstream or provide options to do so.

Fix

Reference previous nodes by name, not through $json. In n8n, the syntax is: $('Node Name').first().json.

const state = $('Route Decision').first().json.state; const customer = state.customer; const llmOutput = $json.output; // AI Agent output

This works at any point in the pipeline regardless of what sits between the nodes. n8n keeps the results of every node during workflow execution, and $(...) gives you direct access to them.

Principle

In an n8n workflow with an AI Agent, never do not rely on $json for state. Always use $('SpecificNode').first().json. This trivial refactor eliminates an entire class of errors, and the named dependency is easier to read in review.

Bug #2 — Postgres executeQuery replaces the input

Bug 02

After Postgres executeQuery, the original input is unavailable as $json

Symptom

You created a Postgres node with the Execute Query operation (for example, a SELECT from the knowledge base). The next node is Code, which must use both the SQL result, and and data from previous nodes. $json contains only the SELECT columns. Data from before Postgres is no longer visible.

Repro

Detect Language (sets session_id, chat_id) → Postgres executeQuery SELECT * FROM kb_faq WHERE ... → Code node:

const faqRows = $input.all(); // rows from SQL const chatId = $json.chat_id; // undefined! const sessionId = $json.session_id; // undefined!
Cause

The Postgres node in executeQuery mode replaces the input with the SELECT result. Every SQL row becomes a separate item in the output. Original fields from previous nodes are not merged. This differs from Insert/Update operations, where return can be enabled.

The confusion is made worse by the fact that some Postgres operations (Insert with returnAll) behave differently — there, input may partially pass through.

Fix

The same pattern as with the AI Agent — reference the previous node by name:

const faqRows = $input.all(); const chatId = $('Detect Language').first().json.chat_id; const sessionId = $('Detect Language').first().json.session_id;

This has another advantage: it makes dependencies explicit. If someone renames Detect Language, the workflow will fail immediately with a clear message instead of silently working with undefined.

Bug #3 — Load node stalls on an empty SELECT

Bug 03

Workflow stops when SELECT returns 0 rows

Symptom

Load Session State for a new session (the session_id is not yet in the database). SELECT returns 0 rows. The next nodes do not run. The workflow looks “stuck.” In the executions dashboard, the status is Waiting or the run simply ends without an error.

Repro
// Load Session State (Postgres executeQuery) SELECT * FROM puramur_sessions_state WHERE session_id = $1; // Query Parameters ={{ [$('Detect Language').first().json.session_id] }}

For a new user, this SELECT returns 0 rows. The next Code node, “Initialize State,” never runs. There is no error and no output; the workflow simply stops at Load Session State.

Cause

By default, an n8n node with zero items output stops the pipeline. The logic is “if there is no result, there is nothing to pass downstream.” That is fine in many cases, but it breaks the LOAD pattern for state — exactly when a new session has no row, you need to create a default state rather than stop.

Fix

In the Postgres node, open the Settings tab (not Parameters!) and enable Always Output Data. Now, when there are 0 rows, the node outputs one empty item; your Init State code checks it and builds a default state.

// Initialize State (Code node, runOnceForAllItems) const upstream = $('Detect Language').first().json; const rows = $input.all(); let state; if (rows.length === 0 || !rows[0].json.session_id) { // New session — default state state = { session_id: upstream.session_id, stage: 'greeting', stage_iteration: 0, customer: {}, pet: {}, cart: [], is_new_session: true, }; } else { // Existing session — parse const row = rows[0].json; state = { ...row, is_new_session: false }; } return [{ json: { ...upstream, state }}];
Where else this applies

Always Output Data is needed on any Postgres/HTTP/Split node that can legitimately return 0 items. Rule: if a node reads external data and 0 items is a valid business case, enable the toggle.

Bug #4 — Merge combineByPosition stalls on an empty branch

Bug 04

Merge does not run if one input is empty

Symptom

You have a workflow with an IF node that splits into two paths. Both paths are merged back through Merge (mode: combineByPosition). When IF routes to one branch, the other is empty, and Merge does not fire. The workflow looks “stuck” on the Merge node.

Repro
Route Decision (Code) ↓ Switch by Route (5 outputs: pricing / faq / handoff / off_topic / sales) ↓ ↓ Pricing branch (Postgres tool) Sales branch (RAG) ↓ ↓ Merge (combineByPosition) ← stalls ↓ Build Prompt

When Route Decision classifies a query as pricing, only the pricing branch runs. The sales branch outputs 0 items. Merge combineByPosition waits for items on both inputs — and stalls.

Cause

combineByPosition combines items by index — item[0] from the left input + item[0] from the right input → merged item[0]. If one input has 0 items, there is no pair for index 0. Depending on the n8n version, the node either stalls, returns empty output, or throws an internal error.

This is a fundamental mismatch between the developer’s mental model (“I am merging branches”) and what the mode actually does (“I am joining items by position”).

Fix

Use Merge mode append instead of combineByPosition. Append simply concatenates items from both inputs. An empty branch contributes 0 items; the non-empty branch contributes its items. Build Prompt then reads what arrived and builds the prompt.

// Merge node config { "mode": "append" // not "combineByPosition" } // Build Prompt (Code) — handles one or more items const items = $input.all(); const route = $('Route Decision').first().json.route; let context; if (route === 'pricing') { // items here are pricing rows context = formatPricingContext(items.map(i => i.json)); } else if (route === 'sales') { // items — RAG chunks context = formatRagContext(items.map(i => i.json)); }

An alternative is to use separate AI Agent nodes for each branch and connect them without Merge at all: Switch → separate AI Agent per route → separate Extract → shared Save. But this multiplies the number of nodes in the workflow, so append + a branch-aware Build Prompt is usually better.

Bug #5 — queryReplacement with $json breaks after Postgres

Bug 05

Postgres SAVE node breaks with cryptic type errors

Symptom

SAVE Session State (Postgres INSERT/UPSERT) throws errors such as:

error: column "customer" is of type jsonb but expression is of type text error: invalid input syntax for type integer: "undefined" error: null value in column "session_id" violates not-null constraint

This happens only when another Postgres node sits before SAVE (for example, a SELECT used for verification). If you test it in isolation with clean data, everything works.

Repro
// SAVE Postgres node, executeQuery mode INSERT INTO puramur_sessions_state ( session_id, stage, customer, pet, cart_total ) VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::numeric); // Query Parameters (problematic) ={{ [ $json.session_id, $json.stage, JSON.stringify($json.customer), JSON.stringify($json.pet), $json.cart_total ] }}

Here, $json points to the previous Postgres node (SELECT). But the fields session_id, stage, customer come from state (generated by Extract State code), not from the SELECT result.

Cause

Three things overlap:

  1. $json in Query Parameters refers to the direct predecessor in the pipeline. If that is a Postgres node, $json = a SELECT row, not state from Extract.
  2. If the field is missing (SELECT does not contain session_id) — $json.session_id = undefined. n8n serializes this into the string "undefined", and Postgres throws a type error.
  3. JSONB fields require an explicit JSON.stringify. Without it, n8n passes a JavaScript object as the string "[object Object]", which Postgres cannot parse as JSONB.
Fix

Three rules for Query Parameters in Postgres save nodes:

={{ [ // (1) Explicit references to the nodes that own the data $('Extract State Update').first().json.state.session_id, $('Extract State Update').first().json.state.stage, // (2) JSON.stringify for JSONB fields JSON.stringify($('Extract State Update').first().json.state.customer || {}), JSON.stringify($('Extract State Update').first().json.state.pet || {}), // (3) Number/Boolean casts for numeric/boolean fields Number($('Extract State Update').first().json.state.cart_total || 0), Boolean($('Extract State Update').first().json.state.cart_gift_eligible) ] }}

Also, use explicit casts in SQL: $1::text, $3::jsonb, $5::numeric. This is double insurance: if the JavaScript side passes the wrong value, the cast catches it with a clear “cannot cast X to Y” error instead of silently storing stringified junk.

A unified mental model

These five bugs look different, but they share the same root cause: nodes in an n8n workflow do not always pass input through transparently. AI Agent and Postgres executeQuery actively replace input with their own output. Merge combineByPosition does not tolerate asymmetry. Postgres does not continue with 0 items. Query Parameters are linked to the direct predecessor, not to “the last relevant data source.”

The mental model I developed after this debugging journey:

  1. Do not rely on $json. Always reference specific nodes via $('NodeName').first().json.field. This trivial refactor eliminates an entire class of errors.
  2. Reads from DB → Settings → Always Output Data. Empty results are a legitimate business state, not an error.
  3. Merge = append, not combineByPosition. If you truly need a positional JOIN, know exactly what you are doing. In 95% of AI workflows, append is enough.
  4. Postgres save = explicit refs + typecasts. In Query Parameters: $('...').first().json.state.field. In SQL: $1::jsonb. Without this, you are writing “works by luck.”
  5. Test every node with edge cases. 0 rows, undefined fields, an empty branch in Switch. A successful happy-path test does not cover these bugs.

Pre-deploy checklist

  • All references to previous nodes via $('NodeName').first().json, not $json
  • All Postgres read nodes — Settings → Always Output Data: ON
  • Every Init State code node handles rows.length === 0 as a valid path
  • Merge nodes → mode: append (not combineByPosition, except for explicit join scenarios)
  • Query Parameters in Postgres save nodes — explicit refs to the state node
  • JSONB fields → JSON.stringify(...) + SQL cast ::jsonb
  • Numeric fields → Number(...) + ::numeric
  • Boolean fields → Boolean(...) + ::boolean
  • UPSERT for history fields (handoff_triggered_at) → COALESCE(existing, new)
  • Test cases: (a) new session_id, (b) existing session_id, (c) each Switch branch separately