01 Overview

This is the fifth article in the series. In the previous ones, we covered:

  • #1 — why a RAG reference bot does not sell, and what to replace it with
  • #2 — session state in Postgres as the foundation
  • #3 — a semantic router on pgvector, from 6.7% to 93%
  • #4 — 5 n8n bug patterns with fixes

Now we put everything together into one working product. The sales agent for puramur.com.ua is 39 nodes in a single n8n workflow, which receives messages in Telegram, classifies the query, selects products with live prices, builds a cart, collects contact details, and hands a ready-to-process order to a manager.

A simplified view of the pipeline:

Telegram Trigger ↓ Detect Language + Extract session_id ↓ Save User Message (Postgres — conversation log) ↓ Load Session State (Postgres, Always Output Data: ON) ↓ Initialize State (Code) — default for new sessions ↓ Semantic Router (embed → SQL cosine → decide) ↓ Route Decision (Code) — regex primary + LLM router fallback ↓ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Switch by Route: ├─ pricing → SQL pricing_lookup ┐ ├─ faq → RAG kb_faq_view │ ├─ handoff → Direct handoff adapter │→ Merge (append) ├─ off_topic → Redirect prompt │ └─ sales → State-machine branch ┘ ├── greeting ├── discovery ├── presentation (RAG expertise+products + pricing) ├── cart_building ├── contact_collection └── handoff ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ↓ Build Stage-Specific Prompt (Code) ↓ AI Agent (gpt-4o-mini, route+stage-specific system) ↓ Extract State Update (Code) — parse markers from LLM output ↓ Upsert Session State (Postgres UPSERT) ↓ Save Bot Message (Postgres — conversation log) ↓ IF handoff_triggered_at (idempotency check) ├── Notify Manager (Telegram to manager chat_id) └── (skip if already notified) ↓ Reply to User (Telegram sendMessage)

It looks complicated at first glance. But every block has one clear job. And everything lives in a single workflow, with no inter-service calls. Latency from trigger to user response is 3–5 seconds for an average query.

02 State machine — 8 stages

The key conceptual difference between v1 (a reference consultant) and v2 (a sales agent) is explicit funnel stages. The customer is not “just chatting with a bot” — they move through defined paths. Each stage has entry conditions, exit conditions, and a stage-specific prompt.

greeting

Entry

A new session (is_new_session=true) or the first message after a long pause.

Job

Greet the user and identify the basic intent — a product question, pet consultation, or something else.

Exit

Automatic transition to discovery on the first meaningful message (not just “hello”). If the customer immediately asks about price, the router intercepts it into the pricing branch and the stage remains greeting.

discovery

Entry

After greeting, or from presentation when the LLM needs to clarify the profile.

Job

Collect the pet profile (type, breed, age, problems) and customer profile (name). Ask 1–2 questions at a time instead of running a long questionnaire.

Exit

Automatic transition to presentation when discovery_completeness >= 60. Formula: +25 pet.type, +25 pet.problems, +25 pet.breed, +15 pet.age, +5 pet.name, +5 customer.name.

presentation

Entry

discovery_completeness >= 60. Or from cart_building when the customer asks to see more options.

Job

Show 1–3 suitable products. Use RAG (expertise + products) plus the pricing tool for live prices. Do not recommend products that were already shown (state.presented_products).

Exit

To cart_building — when the LLM detects buy intent (regex + LLM marker). To discovery — when the LLM decides the profile needs clarification (rare).

cart_building

Entry

Buy intent from presentation. The customer says “I’ll take it,” “add it,” “checkout,” and so on.

Job

Build the cart through <cart_action> block in the LLM output. Auto-append the gift (Brilliant Gloss PR244124) when cart_total >= 1000. Mark free_delivery at 3000+.

Exit

To contact_collection — when the customer confirms checkout (“place it,” “go ahead,” “ok”). Back to presentation — if they want to see more products.

contact_collection

Entry

Confirm intent from cart_building.

Job

Collect customer.name and customer.phone. Regex is used to extract the phone number from the message (phone: /\+?3?8?\s?0?\d{2}[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}/). Normalize it to +380XXXXXXXXX.

Exit

To handoff — when both name and phone are available. This also triggers a one-time notification to the manager.

handoff

Entry

One of the following conditions: (a) contact_collection is complete (name + phone), (b) LLM marker <handoff/>, (c) route=handoff from the semantic router, (d) explicit complaint keywords.

Job

Final message to the customer: “a manager will contact you within 15 minutes.” An idempotent notification is sent to the manager’s Telegram channel with a cart summary and contact card.

Exit

None. The session remains in handoff until the conversation naturally ends.

Transitions are defined in code, not in the prompt. The prompt can only emit <next_stage>X</next_stage>, which we then validate against the allowed transitions:

const allowedTransitions = {
  greeting:           ['greeting', 'discovery'],
  discovery:          ['discovery', 'presentation'],
  presentation:       ['presentation', 'discovery', 'cart_building'],
  cart_building:      ['cart_building', 'presentation', 'contact_collection'],
  contact_collection: ['contact_collection', 'handoff'],
  handoff:            ['handoff'],
};

function validateTransition(currentStage, requestedStage) {
  if (!requestedStage) return currentStage;
  const allowed = allowedTransitions[currentStage] || [currentStage];
  return allowed.includes(requestedStage) ? requestedStage : currentStage;
}

The LLM may try to jump straight from greeting to cart_building — the code will not allow it. This guards against unstable model behavior and, more importantly, guarantees that anyone reading the workflow sees the actual transition graph, not “whatever the LLM decides.”

03 Route Decision — regex extraction pre-LLM

One of the important v2 optimizations is extracting primary data from the message before calling the LLM. This is more robust and faster.

// Route Decision (Code node, runOnceForAllItems)
const upstream = $('Initialize State').first().json;
const semanticResult = $('Semantic Router Decision').first().json;
const message = upstream.message_text.toLowerCase();
const state = upstream.state;

// (1) Regex extraction for pet type/breed/problems
const breedPatterns = {
  'йорк': 'Yorkshire Terrier',
  'кокер': 'Cocker Spaniel',
  'британ': 'British Shorthair',
  'сфінкс': 'Sphynx',
  'мейн-кун': 'Maine Coon',
  'персид': 'Persian',
  'шпіц': 'Spitz',
  'хаск': 'Husky',
  'лабрадор': 'Labrador',
  // ... 21 patterns total
};

const problemKeywords = {
  'сохне': 'dry_skin',
  'лущ': 'dry_skin',
  'лине': 'shedding',
  'ковтун': 'matting',
  'бліх': 'parasites',
  'алерг': 'allergy',
  'запах': 'odor',
  'зуд': 'itching',
  // ... 13 keywords total
};

const extracted = { pet: {}, customer: {} };

// Pet type
if (/\b(кіт|кот|кіш|кот[а-я]*)\b/i.test(message)) extracted.pet.type = 'cat';
else if (/\b(собак|пес|песик|цуцен)\b/i.test(message)) extracted.pet.type = 'dog';

// Breed
for (const [pat, breed] of Object.entries(breedPatterns)) {
  if (message.includes(pat)) { extracted.pet.breed = breed; break; }
}

// Problems (multi-match)
const problems = [];
for (const [kw, prob] of Object.entries(problemKeywords)) {
  if (message.includes(kw) && !problems.includes(prob)) problems.push(prob);
}
if (problems.length) extracted.pet.problems = problems;

// Phone (Ukrainian formats)
const phoneMatch = message.match(/\+?3?8?\s?0?\d{2}[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}/);
if (phoneMatch) {
  const digits = phoneMatch[0].replace(/\D/g, '');
  if (digits.length >= 9) {
    const normalized = '+380' + digits.slice(-9);
    extracted.customer.phone = normalized;
  }
}

// Merge extracted data into state (do not overwrite existing values)
const newPet = { ...state.pet };
if (extracted.pet.type && !newPet.type) newPet.type = extracted.pet.type;
if (extracted.pet.breed && !newPet.breed) newPet.breed = extracted.pet.breed;
if (extracted.pet.problems) {
  const existing = new Set(newPet.problems || []);
  extracted.pet.problems.forEach(p => existing.add(p));
  newPet.problems = [...existing];
}

const newCustomer = { ...state.customer };
if (extracted.customer.phone && !newCustomer.phone) {
  newCustomer.phone = extracted.customer.phone;
}

// (2) Calculate discovery_completeness
const completeness = 
  (newPet.type ? 25 : 0) +
  (newPet.problems?.length ? 25 : 0) +
  (newPet.breed ? 25 : 0) +
  (newPet.age_years ? 15 : 0) +
  (newPet.name ? 5 : 0) +
  (newCustomer.name ? 5 : 0);

// (3) Auto-transition greeting → discovery if pet.type exists
let nextStage = state.stage;
if (state.stage === 'greeting' && newPet.type) nextStage = 'discovery';
if (state.stage === 'discovery' && completeness >= 60) nextStage = 'presentation';

const updatedState = {
  ...state,
  pet: newPet,
  customer: newCustomer,
  discovery_completeness: completeness,
  stage: nextStage,
};

return [{
  json: {
    ...upstream,
    state: updatedState,
    route: semanticResult.route,
    route_confidence: semanticResult.confidence,
  }
}];

This code does three things before the LLM sees the message. First, it extracts explicit facts with regex — more reliably and at no cost. Second, it updates discovery_completeness. Third, it auto-transitions the stage when the conditions are met. This is critical — without it, the LLM receives a stale stage and generates the wrong prompt.

Why pre-LLM

In an earlier version, I performed regex extraction after the LLM in the Extract State Update node. That failed for the scenario where the customer’s first message was “I have a Sphynx with dry skin” — the greeting prompt had no pet context and asked “what kind of pet do you have?” The customer had to repeat themselves. Moving regex before Route Decision enabled a chained greeting→discovery→presentation transition in a single turn for information-rich first messages.

04 5 route branches in Switch

After Route Decision, the message enters the Switch by Route node, which has five outputs. Each output is a separate processing branch with different tools.

RouteToolPrompt strategy
pricing SQL against the pricing view Compact, with facts: SKU, name, effective_price, stock_status
faq RAG over kb_faq_view Answer + 1 line on relevance
handoff Direct handoff adapter Minimal prompt: confirm + closing message
off_topic None Redirect prompt: acknowledge → return to Puramur
sales Depends on the stage Stage-specific prompt (see the Presentation section)

The sales branch is the main path and the most complex part of the workflow. The rest of the article focuses on it, with a deep dive into the presentation stage.

05 Presentation branch — RAG + pricing

This is the most complex branch. The goal at stage=presentation is to take the pet profile, find relevant products through RAG, enrich them with live prices via the SQL tool, and assemble everything into a prompt for the LLM.

Sales branch ↓ IF stage == 'presentation' ↓ Combined RAG (Postgres executeQuery — UNION) ↓ Extract SKUs (Code) — extract unique SKUs from product chunks ↓ Call Pricing Tool (internal HTTP or inline SQL) ↓ Merge RAG + Pricing (Code) ↓ Build Presentation Prompt (Code) ↓ AI Agent

Combined RAG UNION

Instead of two separate RAG calls (one for expertise chunks and another for product chunks), I combined them into a single SQL UNION query. This gives higher recall and an immediately ranked result.

-- Combined RAG (Postgres, executeQuery)
WITH query_embedding AS (
  SELECT $1::vector AS emb
)
SELECT 
  'expertise' AS chunk_type,
  chunk_id, content, metadata,
  1 - (embedding <=> (SELECT emb FROM query_embedding)) AS similarity
FROM kb_expertise_view
WHERE 1 - (embedding <=> (SELECT emb FROM query_embedding)) > 0.35

UNION ALL

SELECT
  'product' AS chunk_type,
  chunk_id, content, metadata,
  1 - (embedding <=> (SELECT emb FROM query_embedding)) AS similarity
FROM kb_products_view
WHERE 1 - (embedding <=> (SELECT emb FROM query_embedding)) > 0.35

ORDER BY similarity DESC
LIMIT 8;

The query embedding is prepared in the previous node from a synthetic query, built from the pet profile:

const pet = state.pet;
const queryParts = [];
if (pet.type) queryParts.push(pet.type === 'cat' ? 'кіт' : 'собака');
if (pet.breed) queryParts.push(pet.breed);
if (pet.problems?.length) queryParts.push(pet.problems.join(', '));
const ragQuery = queryParts.join(' ');   
// Example: "cat Sphynx dry_skin sensitive_skin"

We get 8 chunks — a mix of expertise (how to care for a Sphynx with dry skin) and products (specific SKUs). Then we extract unique SKUs from the product chunks:

// Extract SKUs (Code node)
const rows = $input.all().map(i => i.json);

const expertiseChunks = rows.filter(r => r.chunk_type === 'expertise');
const productChunks = rows.filter(r => r.chunk_type === 'product');

// SKUs from product-chunk metadata
const skusRaw = productChunks.map(p => p.metadata?.sku).filter(Boolean);
const uniqueSkus = [...new Set(skusRaw)];

// Exclude products already shown
const presented = new Set((state.presented_products || []).map(p => p.sku));
const newSkus = uniqueSkus.filter(sku => !presented.has(sku));

return [{
  json: {
    ...upstream,
    expertise_chunks: expertiseChunks.slice(0, 3),   // top-3 expertise chunks
    candidate_skus: newSkus.slice(0, 5),           // up to 5 SKUs for pricing lookup
  }
}];

Call Pricing Tool

The next node calls pricing_lookup — a separate tool designed earlier while working on the router (covered in detail in the first article). It returns, for each SKU: effective_price (including discounts), stock_status, available_for_cart, available_for_mention, reason_if_unavailable, and alternatives.

-- Pricing lookup (Postgres executeQuery)
SELECT 
  sku, name, category,
  price, price_special,
  COALESCE(price_special, price) AS effective_price,
  status AS stock_status,
  CASE
    WHEN sku = 'PR244124' THEN FALSE
    WHEN status = 'Знято з виробництва' THEN FALSE
    WHEN status = 'Очікується' THEN FALSE
    WHEN status = 'В наявності' THEN TRUE
    ELSE FALSE
  END AS available_for_cart,
  CASE
    WHEN status = 'Знято з виробництва' THEN FALSE
    ELSE TRUE
  END AS available_for_mention,
  CASE
    WHEN sku = 'PR244124' THEN 'for_humans_only'
    WHEN status = 'Знято з виробництва' THEN 'discontinued'
    WHEN status = 'Очікується' THEN 'backorder'
    ELSE NULL
  END AS reason_if_unavailable
FROM kb_products_view
WHERE sku = ANY($1::text[]);

This is a critical part. This is where business rules are encoded directly in SQL instead of being left to the LLM:

  • PR244124 (Brilliant Gloss) — for_humans_only, never for_cart
  • Discontinued — neither for_cart nor for_mention
  • Backorder — for_mention (it can be mentioned as “expected”), not for_cart
  • In stock — both

06 Presentation prompt template

Once we have RAG expertise chunks + pricing data + state, we build the prompt. This is the most important code in the workflow — recommendation quality depends on it.

// Build Presentation Prompt (Code node)
const state = $('Route Decision').first().json.state;
const expertise = $('Extract SKUs').first().json.expertise_chunks;
const pricingRows = $input.all().map(i => i.json);

// Format expertise block
const expertiseText = expertise.map((e, i) => 
  `[E${i+1}] ${e.content}`
).join('\n\n');

// Format products block — with instructions for the LLM
const productsText = pricingRows.map(p => {
  const lines = [
    `SKU: ${p.sku}`,
    `Name: ${p.name}`,
    `Price: ${p.effective_price} UAH` + 
      (p.price_special ? ` (sale price, regular ${p.price})` : ''),
    `Status: ${p.stock_status}`,
  ];
  if (!p.available_for_cart && p.reason_if_unavailable) {
    lines.push(`⚠️ Not available for cart: ${p.reason_if_unavailable}`);
    if (p.reason_if_unavailable === 'for_humans_only') {
      lines.push(`⚠️ This is cosmetics for HUMANS, do not recommend it for pets`);
    }
  }
  return lines.join('\n');
}).join('\n\n---\n\n');

// State summary for the LLM (compact JSON)
const stateBlock = JSON.stringify({
  stage: state.stage,
  pet: state.pet,
  cart: state.cart,
  cart_total: state.cart_total,
  presented_before: (state.presented_products || []).map(p => p.sku),
});

const systemPrompt = `You are a puramur.com.ua consultant at the PRESENTATION stage.

TASK: show the customer 1–2 suitable products using the live prices below.
Use the expertise to explain WHY these products are a good fit.

DIALOG STATE:
${stateBlock}

EXPERTISE (use as context for explanations):
${expertiseText}

LIVE PRICES AND AVAILABILITY (do not invent, use only these):
${productsText}

RULES:
1. Do not recommend products marked ⚠️ Not available for cart
2. Always mention the SKU and price when recommending a product
3. Do not recommend more than 2 products in one message
4. If the customer is ready to buy — emit 
5. Emit cart_building when there is buy intent
6. Emit discovery if the profile needs clarification
7. Emit  for each displayed SKU (for tracking)

RESPONSE FORMAT:
A natural response to the customer in English. Markers at the end.`;

return [{ json: { ...state, system_prompt: systemPrompt }}];

The key point here is four context blocks: state summary, expertise, live prices, and rules. Each is separate. The LLM sees structured information instead of a “wall of text” and follows the rules better.

Prompt size

The presentation prompt, including expertise and prices, is 1200–1800 tokens. Compared with the universal v1 prompt (3500 tokens on every message regardless of context), this is almost twice as small and far more relevant. You save money on every turn, and the LLM follows instructions better when there are fewer of them and they are targeted.

07 Cart operations

The cart is a JSONB array in session state. It is updated through <cart_action> blocks in the LLM output, which the code parses and applies with business rules.

LLM emit format

// Example LLM output after buy intent
Great choice! I’m adding Exotic Spa (PR243587) to your cart — 
it’s our gentlest shampoo, ideal for a Sphynx with sensitive skin. 
For 277 UAH you get a 250 ml bottle, enough for about two months 
of regular bathing.

Would you like to add Delicate Care conditioner for daily moisturizing?

<cart_action type="add" sku="PR243587" qty="1"/>
<presented sku="PR243587"/>
<presented sku="PR243601"/>
<next_stage>cart_building</next_stage>

Cart action parser + gift auto-append

// Extract State Update (Code) — cart section
const llmOutput = $json.output;
const state = $('Route Decision').first().json.state;

// Parse cart_actions
const cartActionRegex = /<cart_action\s+type="(\w+)"\s+sku="([^"]+)"(?:\s+qty="(\d+)")?\/>/g;
const actions = [...llmOutput.matchAll(cartActionRegex)];

let newCart = [...(state.cart || [])];
const pricingLookup = $('Call Pricing Tool').first().json.pricingBySku || {};

for (const [_, type, sku, qtyStr] of actions) {
  const qty = parseInt(qtyStr || '1');
  const pricing = pricingLookup[sku];
  
  // Guard — do not add if not available_for_cart
  if (type === 'add' && pricing?.available_for_cart) {
    const existingIdx = newCart.findIndex(item => item.sku === sku);
    if (existingIdx >= 0) {
      newCart[existingIdx].qty += qty;
      newCart[existingIdx].line_total = newCart[existingIdx].price * newCart[existingIdx].qty;
    } else {
      newCart.push({
        sku,
        name: pricing.name,
        price: pricing.effective_price,
        qty,
        line_total: pricing.effective_price * qty,
        added_at: new Date().toISOString(),
        is_gift: false,
      });
    }
  }
  
  if (type === 'remove') {
    newCart = newCart.filter(item => item.sku !== sku);
  }
}

// Calculate total WITHOUT gifts
const nonGiftTotal = newCart
  .filter(item => !item.is_gift)
  .reduce((sum, item) => sum + item.line_total, 0);

// Gift auto-append: PR244124 when cart_total >= 1000
const giftAlready = newCart.some(item => item.is_gift && item.sku === 'PR244124');
if (nonGiftTotal >= 1000 && !giftAlready) {
  newCart.push({
    sku: 'PR244124',
    name: 'Brilliant Gloss (gift on orders from 1000 UAH)',
    price: 0,
    qty: 1,
    line_total: 0,
    added_at: new Date().toISOString(),
    is_gift: true,
  });
} else if (nonGiftTotal < 1000 && giftAlready) {
  // Customer removed an item and dropped below the threshold — remove the gift
  newCart = newCart.filter(item => !(item.is_gift && item.sku === 'PR244124'));
}

// Free delivery marker
const freeDelivery = nonGiftTotal >= 3000;

const updatedState = {
  ...state,
  cart: newCart,
  cart_item_count: newCart.length,
  cart_total: nonGiftTotal,
  cart_gift_eligible: nonGiftTotal >= 1000,
  cart_free_delivery: freeDelivery,
};

There are several important details here. First, we check available_for_cart even if the LLM emitted add — this is the second guard against model errors (the first was in the prompt). Second, gift auto-append is bidirectional — we add the gift when the threshold is exceeded and remove it when the total drops below it (the customer may remove an item from the cart). Third, cart_total is calculated without gift items — otherwise the 1000 threshold becomes tricky, and a customer with a 990 UAH cart plus a gift could produce a false-positive “free delivery” state at 3000+.

08 Contact collection

At the contact_collection stage, the bot asks for a name and phone number. Code-side regex extraction backs up the LLM — even if the customer writes the phone number in an unusual format, we detect and normalize it.

// Phone extraction and normalization
function extractPhone(text) {
  // Ukrainian formats: +380..., 0..., 380...
  const pattern = /\+?3?8?\s?0?\d{2}[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}/;
  const match = text.match(pattern);
  if (!match) return null;
  
  const digits = match[0].replace(/\D/g, '');
  if (digits.length < 9) return null;
  
  // Take the last 9 digits (mobile operator + number)
  // and add +380
  return '+380' + digits.slice(-9);
}

// Name extraction — flexible
function extractName(text, currentName) {
  if (currentName) return currentName;
  
  // Patterns: "My name is X", "I am X", "It's X here"
  const patterns = [
    /мене\s+звати\s+([А-ЯҐЄІЇа-яґєії\s\-]{2,40})/i,
    /я\s+([А-ЯҐЄІЇ][а-яґєії]+)(?:\s+([А-ЯҐЄІЇ][а-яґєії]+))?/,
    /моє\s+ім[’'`]я\s+([А-ЯҐЄІЇа-яґєії\s\-]{2,40})/i,
  ];
  for (const p of patterns) {
    const m = text.match(p);
    if (m) return m[1].trim().split(/\s+/).slice(0, 3).join(' ');
  }
  return null;
}

Name recognition is always harder than phone parsing — regex catches common constructions, while for the rest (short answers such as “Nicholas”) we rely on LLM extraction via the <customer_name>X</customer_name> marker in the output.

When and customer.name and customer.phone are filled in — the stage automatically transitions to handoff:

const hasFullContact = !!(newCustomer.name && newCustomer.phone);
if (state.stage === 'contact_collection' && hasFullContact) {
  updatedState.stage = 'handoff';
  updatedState.handoff_triggered_at = state.handoff_triggered_at || new Date().toISOString();
  updatedState.handoff_reason = state.handoff_reason || 'cart_confirmed';
}

09 Handoff — multi-trigger + idempotency

Handoff can be triggered from four sources, each with its own reason. This matters for analytics (where it came from, the cart value, and how long it took).

// Handoff detection in Extract State Update
const hasComplaint = /\b(скарг|незадоволен|повернути|верніть|повернен)/i.test(userMessage);
const llmMarker = /<handoff\s*\/?>/i.test(llmOutput);
const routeHandoff = route === 'handoff';
const completedContact = state.stage === 'contact_collection' && hasFullContact;

const shouldTriggerHandoff = 
  completedContact || hasComplaint || routeHandoff || llmMarker;

let handoffReason = state.handoff_reason;
if (shouldTriggerHandoff && !state.handoff_triggered_at) {
  handoffReason = 
    completedContact ? 'cart_confirmed' :
    hasComplaint     ? 'complaint' :
    routeHandoff     ? 'explicit_request' :
                       'llm_decision';
}

const alreadyNotified = !!state.handoff_triggered_at;

updatedState.handoff_triggered_at = 
  state.handoff_triggered_at || 
  (shouldTriggerHandoff ? new Date().toISOString() : null);
updatedState.handoff_reason = handoffReason;
updatedState.stage = shouldTriggerHandoff ? 'handoff' : state.stage;

// For the next IF node — flag whether a notification is needed
$json.should_notify_manager = shouldTriggerHandoff && !alreadyNotified;

The key detail is alreadyNotified. This is the difference between “handoff already happened” and “handoff just happened in this turn.” Only the second case sends a manager notification. This prevents duplicate alerts when the customer sends multiple messages after handoff.

Manager notification payload

// Notify Manager (Telegram sendMessage)
const state = $('Extract State Update').first().json.state;
const customer = state.customer;
const pet = state.pet;
const cart = state.cart.filter(i => !i.is_gift);
const gift = state.cart.find(i => i.is_gift);

const cartText = cart.map(i => 
  `• ${i.name} (${i.sku}) × ${i.qty} = ${i.line_total}₴`
).join('\n');

const problems = (pet.problems || []).join(', ') || 'not provided';

const message = `🔔 NEW CUSTOMER FOR CHECKOUT

Reason: ${state.handoff_reason}
Chat ID: ${state.session_id}

━━━ Customer ━━━
Name: ${customer.name || 'not provided'}
Phone: ${customer.phone || 'not provided'}

━━━ Pet ━━━
Type: ${pet.type || '—'}
Breed: ${pet.breed || '—'}
Problems: ${problems}

━━━ Cart ━━━
${cartText || 'empty'}
${gift ? \`🎁 Gift: ${gift.name}\` : ''}
Total: ${state.cart_total} UAH
${state.cart_free_delivery ? '🚚 Free delivery' : ''}`;

// send to manager Telegram chat
return [{ json: { chat_id: MANAGER_CHAT_ID, text: message }}];

This is what the manager receives in Telegram. A ready-to-use card: name, phone number, pet profile, assembled cart, and a free-delivery flag. The manager calls, confirms the details, and creates the order in the CRM. The bot’s job is to deliver a hot lead with as much context as possible.

10 Extract State Update — full code

This is the main node that collects all state updates from the LLM output. It runs after the AI Agent and before UPSERT.

// Extract State Update (Code, runOnceForAllItems)
const llmOutput = $json.output;
const upstream = $('Route Decision').first().json;
const state = upstream.state;
const userMessage = upstream.message_text;
const route = upstream.route;

// (1) Parse markers from LLM output
const nextStageMatch = llmOutput.match(/<next_stage>(\w+)<\/next_stage>/);
const customerNameMatch = llmOutput.match(/<customer_name>([^<]+)<\/customer_name>/);
const customerPhoneMatch = llmOutput.match(/<customer_phone>([^<]+)<\/customer_phone>/);
const presentedMatches = [...llmOutput.matchAll(/<presented\s+sku="([^"]+)"\/?>/g)];

// (2) Extract from user message via regex
const phoneFromMsg = extractPhone(userMessage);
const nameFromMsg = extractName(userMessage, state.customer?.name);

// (3) Merge customer updates (priority: user msg > LLM output > existing)
const newCustomer = { ...state.customer };
if (nameFromMsg && !newCustomer.name) newCustomer.name = nameFromMsg;
else if (customerNameMatch && !newCustomer.name) newCustomer.name = customerNameMatch[1].trim();

if (phoneFromMsg && !newCustomer.phone) newCustomer.phone = phoneFromMsg;
else if (customerPhoneMatch && !newCustomer.phone) newCustomer.phone = customerPhoneMatch[1].trim();

// (4) Presented products tracking
const newPresented = [...(state.presented_products || [])];
const presentedSet = new Set(newPresented.map(p => p.sku));
for (const [_, sku] of presentedMatches) {
  if (!presentedSet.has(sku)) {
    newPresented.push({
      sku,
      presented_at: new Date().toISOString(),
      stage_iteration: state.stage_iteration,
    });
    presentedSet.add(sku);
  }
}

// (5) Cart operations (see section 07)
// ... cart parsing and gift auto-append code ...

// (6) Handoff detection (see section 09)
// ... shouldTriggerHandoff code ...

// (7) Stage transition with validation
const requestedStage = nextStageMatch?.[1];
let nextStage = validateTransition(state.stage, requestedStage);

// Override — auto-transitions from business rules
if (shouldTriggerHandoff) nextStage = 'handoff';
if (nextStage === state.stage) {
  // No transition — increment iteration
  updatedState.stage_iteration = state.stage_iteration + 1;
} else {
  // Transition — reset iteration and append to history
  updatedState.stage_iteration = 0;
  updatedState.stage_history = [
    ...(state.stage_history || []),
    { stage: state.stage, exited_at: new Date().toISOString() }
  ];
}

// (8) Bot response — LLM output minus markers
const botResponse = llmOutput
  .replace(/<next_stage>[^<]*<\/next_stage>/g, '')
  .replace(/<cart_action[^\/>]*\/?>/g, '')
  .replace(/<presented[^\/>]*\/?>/g, '')
  .replace(/<customer_(name|phone)>[^<]*<\/customer_\1>/g, '')
  .replace(/<handoff\s*\/?>/g, '')
  .trim();

// Auto-handoff response override
let finalResponse = botResponse;
if (shouldTriggerHandoff && !alreadyNotified) {
  finalResponse = buildHandoffResponse(updatedState);
}

return [{
  json: {
    ...upstream,
    state: updatedState,
    bot_response: finalResponse,
    should_notify_manager: shouldTriggerHandoff && !alreadyNotified,
  }
}];

The function buildHandoffResponse builds the final customer message with a cart summary:

function buildHandoffResponse(state) {
  const cart = state.cart.filter(i => !i.is_gift);
  const cartLines = cart.map(i => `• ${i.name} × ${i.qty} — ${i.line_total}₴`).join('\n');
  const gift = state.cart.find(i => i.is_gift);
  
  return `Thank you, ${state.customer.name}! Here is what I’m sending to the manager:

${cartLines}
${gift ? `🎁 Gift: ${gift.name}` : ''}

Total: ${state.cart_total} UAH
${state.cart_free_delivery ? '🚚 Free delivery' : ''}

A manager will contact you within 15 minutes at ${state.customer.phone} 
to confirm the details and place the order. Have a great day! 🐾`;
}

Instead of letting the LLM “write something” at handoff, we use a strict template with real data. The customer sees exact amounts, exact products, and the exact phone number that will be called.

11 Reflections

Building this workflow took more than three weeks after v1 was deemed a failure. That may sound like a lot for an agent in a single industry — but the same approach can now be replicated 3–5 times faster for other e-commerce clients because the patterns are proven and encoded into a workflow skeleton.

The main lesson I took from this work is that a sales AI agent is not an “AI task”; it is state machine with the LLM acting as the text generator. The complexity is not in the prompt. It is in explicit stages, exit conditions, tools, data flow, and guardrails. The prompt is only one of four components in each stage branch and, in a balanced implementation, accounts for maybe 15–20% of the effort. The rest is code, SQL, structured extraction, and state management.

The second important lesson is that tools determine quality. An LLM with pricing_lookup and live prices can sell. An LLM that “looks for the price in RAG” will make things up. Properly designed structured tools with business rules inside SQL remove entire classes of potential errors from the LLM. This is especially clear in the Brilliant Gloss trap — it is solved with a small piece of logic in the pricing tool, not a 3,000-token prompt instruction.

Third: observability from day one. Session state in JSONB plus a few SQL views gives you what v1 with Window Buffer Memory never could: funnel analysis, drop-off metrics, time per stage, and which products convert. You can literally see where people get stuck and what to do about it. Without this, the agent is a black box with no optimization tools.

And finally: the debugging journey is not wasted. Those five bug patterns from the fourth article, the thresholds from the third, the session state from the second, and the anti-patterns from the first are now checkpoints in my head. The next sales agent will be faster and cleaner not because n8n became simpler, but because I know where to look from day one. That is what you get from reading the entire series — two weeks of my debugging experience, distilled for you to apply.

Now it’s your turn

If you are building an AI agent for e-commerce and want to avoid the same path, this five-article series is enough to start directly with the v2 architecture, without the v1 failure. Session state → semantic router → state machine with explicit stages → tools with business rules → observability queries → regular Bruno testing. That is the full stack.