Skip to content

Order Status Conversational Chat Flow

This document is the plan for a guided, conversational order status experience inside the AI chat widget. When a customer asks about their order, the AI proactively asks for the order number, parses whatever format the customer types (e.g. order: 222, #222, just 222), fetches live data from Shopify Admin API, and replies with full order details — all without storing any order data in the database.

Prerequisites: Phase 12 (on-the-fly order lookup via order-lookup.server.js) complete; read_orders scope granted; fetchOrderByShop, formatOrderSummary, and extractOrderRefFromMessage are already in place. This phase enhances and extends that foundation.


  • Guided conversation: When a customer’s message signals order intent (e.g. “where is my order”, “track my package”, “order status”), the AI asks a clarifying question: “Sure! What is your order number or order ID?” — without making a Shopify API call prematurely.
  • Flexible order ID parsing: Accept any reasonable format the customer types: order: 222, order #222, #222, 222, 1001, order1234, etc. Strip noise, extract the numeric/alphanumeric identifier.
  • Rich Shopify data fetch: Query more order fields than Phase 12 (customer name, email, shipping address, tracking number, fulfillment events, estimated delivery) to give the AI more context for a detailed, accurate reply.
  • Full context to OpenRouter: Pass all fetched order data plus the conversation history into the LLM prompt so the AI can answer naturally: status, items, tracking, estimated arrival, etc.
  • Stateless for orders: Orders are never stored in the database. Each chat turn fetches live data from Shopify. Session state (are we waiting for an order number?) is tracked only in-memory within the RAG flow.
  • Graceful fallbacks: Order not found → helpful message. Multiple orders with same number → show the most recent. Shopify API error → polite error reply. Customer provides invalid input → ask again once, then suggest contacting support.

2. How It Works Today (Reference Baseline)

Section titled “2. How It Works Today (Reference Baseline)”

The current order flow in app/lib/rag.server.js (Phase 12):

User sends message → extractOrderRefFromMessage(message) with regex /#?\d{4,}/ or /order\s+(#?\w+)/i
→ If ref found OR orderNameOrId passed in body → fetchOrderByShop → formatOrderSummary → inject into prompt
→ LLM replies using that order context

Gaps this phase addresses:

GapCurrent BehaviourTarget Behaviour
No order number in messageAI doesn’t ask; replies “I don’t have info”AI detects intent → asks for order number
Order ref parsingOnly matches # + 4+ digits or order <word>Matches order: 222, 222, 1001, order-222, ORD-222 etc.
Shopify data fieldsid, name, createdAt, financial/fulfillment status, total, line items+ customer name, email, phone, shipping address, tracking number, carrier, estimated delivery
Prompt injectionShort plain-text blockStructured, labelled block with all relevant fields
Conversational stateNo tracking of “waiting for order number”awaitingOrderNumber flag in session metadata

Add a metadata JSON column to the ChatSession Prisma model to persist lightweight conversation state without a new table.

// In prisma/schema.prisma — ChatSession model
model ChatSession {
// ... existing fields ...
metadata Json? @default("{}")
}

Shape of metadata:

{
"awaitingOrderNumber": true,
"orderIntentMessageId": "<chatMessageId of the message that triggered order intent>"
}
[Normal chat]
├─ User message has order intent keyword (no order number yet)
│ → Set session.metadata.awaitingOrderNumber = true
│ → AI asks: "Sure! What is your order number or order ID?"
│ → No Shopify call, no embedding, no RAG (direct reply path)
[Awaiting order number]
├─ User replies with something containing a parseable order number
│ → parseOrderRef(message) → orderRef found
│ → Clear awaitingOrderNumber flag
│ → fetchOrderByShop → build rich prompt → LLM reply
├─ User replies with something but no parseable order number
│ → AI replies: "I couldn't find an order number in that. Could you share just the number, e.g. 1001?"
│ → Keep awaitingOrderNumber = true (one retry)
└─ User changes topic entirely (no order signals at all)
→ Clear awaitingOrderNumber flag
→ Treat as normal RAG message

const m = text.match(/#?\d{4,}/) || text.match(/order\s+(#?\w+)/i);

Limitation: requires 4+ digit numbers; misses order: 222, short 3-digit order IDs, ORD-222, etc.

Replace extractOrderRefFromMessage in rag.server.js with a new exported helper in order-lookup.server.js:

/**
* Parse an order reference from any format the customer might type.
* Returns the cleaned ref string (e.g. "1001", "ORD-222") or null.
*
* Handles:
* "order: 222" → "222"
* "order #1001" → "1001"
* "order-1001" → "1001"
* "#1001" → "1001"
* "1001" → "1001" (standalone number, 3+ digits)
* "ORD-222" → "ORD-222"
* "my order is 222" → "222"
*/
export function parseOrderRef(text) {
if (!text || typeof text !== "string") return null;
const t = text.trim();
// "order: 222", "order: #222", "order : 1001"
const colonMatch = t.match(/order\s*:\s*#?(\w+)/i);
if (colonMatch) return colonMatch[1].trim();
// "order #1001", "order 1001", "order-1001"
const wordMatch = t.match(/order[\s\-#]+#?(\w+)/i);
if (wordMatch) return wordMatch[1].trim();
// "#1001" or "#ORD-222"
const hashMatch = t.match(/^#([\w\-]+)/);
if (hashMatch) return hashMatch[1].trim();
// Standalone number: 3+ digits
const numMatch = t.match(/\b(\d{3,})\b/);
if (numMatch) return numMatch[1].trim();
return null;
}

4.3 Intent detection — hasOrderIntent(text)

Section titled “4.3 Intent detection — hasOrderIntent(text)”

New helper to detect when a user is asking about an order (without providing a number yet):

const ORDER_INTENT_KEYWORDS = [
"order status", "my order", "track", "where is my", "when will",
"delivery", "shipped", "shipping", "package", "dispatch", "order #",
"order number", "fulfillment", "out for delivery", "arrived"
];
export function hasOrderIntent(text) {
if (!text) return false;
const lower = text.toLowerCase();
return ORDER_INTENT_KEYWORDS.some((kw) => lower.includes(kw));
}

id, name, createdAt, displayFinancialStatus, displayFulfillmentStatus,
totalPriceSet, lineItems(first: 20) { title, quantity }

Add to fetchOrderByShop in order-lookup.server.js:

query getOrderByName($query: String!) {
orders(first: 1, query: $query) {
edges {
node {
id
name
createdAt
displayFinancialStatus
displayFulfillmentStatus
cancelledAt
cancelReason
note
tags
totalPriceSet { shopMoney { amount currencyCode } }
# Customer
customer {
firstName
lastName
email
phone
}
# Shipping address
shippingAddress {
firstName
lastName
address1
address2
city
province
country
zip
phone
}
# Line items
lineItems(first: 20) {
edges {
node {
title
quantity
variant { sku }
originalUnitPriceSet { shopMoney { amount currencyCode } }
}
}
}
# Fulfillments (tracking)
fulfillments {
status
createdAt
updatedAt
trackingInfo {
number
url
company
}
estimatedDeliveryAt
}
}
}
}
}

Rewrite the formatter to include all new fields, structured for clear AI comprehension:

export function formatOrderSummary(order) {
if (!order) return "";
const lines = [];
lines.push(`Order: ${order.name}`);
lines.push(`Placed: ${order.createdAt}`);
lines.push(`Payment status: ${order.displayFinancialStatus}`);
lines.push(`Fulfillment status: ${order.displayFulfillmentStatus}`);
if (order.cancelledAt) {
lines.push(`Cancelled: ${order.cancelledAt}`);
if (order.cancelReason) lines.push(`Cancel reason: ${order.cancelReason}`);
}
if (order.totalPriceSet?.shopMoney) {
const { amount, currencyCode } = order.totalPriceSet.shopMoney;
lines.push(`Total: ${amount} ${currencyCode}`);
}
// Customer
if (order.customer) {
const c = order.customer;
const name = [c.firstName, c.lastName].filter(Boolean).join(" ");
if (name) lines.push(`Customer: ${name}`);
if (c.email) lines.push(`Customer email: ${c.email}`);
if (c.phone) lines.push(`Customer phone: ${c.phone}`);
}
// Shipping address
if (order.shippingAddress) {
const a = order.shippingAddress;
const addrParts = [a.address1, a.address2, a.city, a.province, a.country, a.zip].filter(Boolean);
lines.push(`Shipping to: ${addrParts.join(", ")}`);
}
// Line items
const items = order.lineItems?.edges ?? [];
if (items.length > 0) {
lines.push("Items ordered:");
items.forEach(({ node }) => {
const price = node.originalUnitPriceSet?.shopMoney;
const priceStr = price ? ` @ ${price.amount} ${price.currencyCode}` : "";
lines.push(` - ${node.title} x${node.quantity}${priceStr}`);
});
}
// Fulfillments and tracking
const fulfillments = order.fulfillments ?? [];
if (fulfillments.length > 0) {
lines.push("Fulfillments:");
fulfillments.forEach((f) => {
lines.push(` Status: ${f.status}`);
if (f.estimatedDeliveryAt) lines.push(` Estimated delivery: ${f.estimatedDeliveryAt}`);
f.trackingInfo?.forEach((t) => {
if (t.number) lines.push(` Tracking: ${t.number}${t.company ? ` (${t.company})` : ""}`);
if (t.url) lines.push(` Tracking URL: ${t.url}`);
});
});
}
if (order.note) lines.push(`Note: ${order.note}`);
return lines.join("\n");
}

The function gains two new behaviours based on session state:

ENTRY
Load session (existing or new)
Read session.metadata (awaitingOrderNumber flag)
[BRANCH A] awaitingOrderNumber === true
→ Try parseOrderRef(message)
→ If found: clear flag, fetch order, build full prompt, call LLM
→ If not found AND topic changed: clear flag, fall through to normal RAG
→ If not found AND topic still looks like order: reply asking again (no LLM call)
[BRANCH B] awaitingOrderNumber !== true
→ Try parseOrderRef(message) OR check orderNameOrId from request body
→ If ref found: fetch order immediately, build full prompt, call LLM
→ If no ref BUT hasOrderIntent(message): set awaitingOrderNumber=true, return clarifying question (NO embed/LLM call)
→ Normal RAG flow otherwise

6.2 Session metadata update helpers (inline in rag.server.js)

Section titled “6.2 Session metadata update helpers (inline in rag.server.js)”
async function setAwaitingOrderNumber(sessionId, value) {
const session = await prisma.chatSession.findUnique({ where: { id: sessionId } });
const meta = (session?.metadata ?? {});
await prisma.chatSession.update({
where: { id: sessionId },
data: { metadata: { ...meta, awaitingOrderNumber: value } },
});
}

When the AI is asking for the order number, we skip embedding and vector search entirely — return a hardcoded clarifying reply:

const CLARIFYING_REPLY = "Sure! Could you please share your order number or order ID? " +
"You can find it in your confirmation email (e.g. #1001 or just 1001).";

This saves embedding cost and latency for what is just a clarifying turn.


7. Prompt Injection Changes — prompt-builder.server.js

Section titled “7. Prompt Injection Changes — prompt-builder.server.js”

The system prompt’s order block is enhanced with a stronger instruction:

--- Order Details (from Shopify, live data) ---
Use this information to answer the customer's questions about their order.
If tracking is available, include the tracking number and URL in your reply.
If the order is cancelled, explain the cancel reason if provided.
If estimated delivery is available, mention it.
{formatted order summary here}

No changes needed to the function signature — buildPrompt(chunks, message, history, { orderContext }) continues to work the same way.


No changes needed to the route handler itself. The orderNameOrId field is already extracted from the request body and passed to generateChatReply. The conversational state logic all lives in the RAG layer.

The frontend widget does not need to send orderName or orderId explicitly — the chat flow handles it conversationally.


9. Frontend Widget Changes — chat-widget.js

Section titled “9. Frontend Widget Changes — chat-widget.js”

The widget already sends { shopDomain, visitorId, sessionId, message } and displays whatever reply comes back. The AI will ask for the order number in natural language and the customer will reply as normal text — no special UI needed.

9.2 Optional: “Order Status” quick-reply button

Section titled “9.2 Optional: “Order Status” quick-reply button”

As a UX enhancement, add a quick-reply chip at the start of a conversation:

[👋 Welcome message]
Quick options: [Track My Order] [Product Questions] [Contact Support]

Clicking “Track My Order” sends the message "I want to check my order status" automatically. The AI will then ask for the order number. This is purely a frontend enhancement and does not require backend changes.

Implementation: In chat-widget.js, after the welcome message renders, inject a <div class="appifire-quick-replies"> with buttons that call the existing sendMessage(text) function. Remove quick-reply buttons after the first user message.


One Prisma migration is required to add metadata to ChatSession:

-- Migration: add metadata column to chat_sessions
ALTER TABLE "chat_sessions" ADD COLUMN "metadata" JSONB DEFAULT '{}';

Prisma schema change:

model ChatSession {
id String @id @default(uuid())
shopId String
visitorId String
displayNumber Int
startedAt DateTime @default(now())
metadata Json? @default("{}") // ← new field
shop Shop @relation(fields: [shopId], references: [id])
messages ChatMessage[]
@@map("chat_sessions")
}

Run: npx prisma migrate dev --name add-chat-session-metadata


FileActionWhat changes
prisma/schema.prismaModifyAdd metadata Json? to ChatSession
app/lib/order-lookup.server.jsModifyAdd parseOrderRef(), hasOrderIntent(); enhance fetchOrderByShop query; rewrite formatOrderSummary
app/lib/rag.server.jsModifyReplace extractOrderRefFromMessage with parseOrderRef/hasOrderIntent; add awaitingOrderNumber state logic; add setAwaitingOrderNumber helper
app/lib/prompt-builder.server.jsModifyStrengthen order block instructions in system prompt
extensions/appifire-chat/assets/chat-widget.jsModify (optional)Add quick-reply buttons for “Track My Order”

Customer: "Where is my order?"
AI: "Sure! Could you please share your order number or order ID?
You can find it in your confirmation email (e.g. #1001 or just 1001)."
Customer: "order: 222"
[Backend: parseOrderRef("order: 222") → "222"
fetchOrderByShop(shop, "222") → order object
formatOrderSummary(order) → rich text block
buildPrompt(chunks, message, history, { orderContext }) → LLM messages
callOpenRouterChat(messages, plan) → reply]
AI: "Here are the details for order **#222**:
- **Status:** Fulfilled ✓
- **Items:** Blue Running Shoes x1 @ 89.99 USD
- **Shipped to:** 123 Main St, New York, NY, USA
- **Tracking:** UPS 1Z999AA10123456784
[Track your package →](https://wwwapps.ups.com/tracking/...)
- **Estimated delivery:** March 28, 2026
Is there anything else I can help you with?"

ScenarioHandling
Order not found in ShopifyAI replies: “I couldn’t find order #222. Double-check the number in your confirmation email, or contact our support team.”
Customer provides garbled input (e.g. “it’s the number I got”)AI asks one more time: “I couldn’t spot an order number there. Could you share just the number, like 1001?” then after second failure: “Let me connect you with our support team instead.”
Shopify API returns error (network/auth)Log error server-side; AI replies: “I’m having trouble retrieving your order right now. Please try again in a moment or contact support.”
read_orders scope not grantedfetchOrderByShop returns { order: null, error: "Shopify API error: 403" }; AI replies with support redirect message
Customer changes topic mid-flow (e.g. “actually, what’s your return policy?”)hasOrderIntent returns false → clear awaitingOrderNumber flag → treat as normal RAG message
Multiple orders same number (shouldn’t happen; Shopify order names are unique per shop)first: 1 in query returns the most recent; note in summary if needed

  • No customer verification: The chat does not verify that the order belongs to the customer. Any visitor who knows or guesses an order number can see its details. This is consistent with Shopify’s standard order status page behaviour (accessible via order number + email or just link).
  • What is exposed: Order name, status, items, shipping address, tracking number. No payment card info is ever returned by the Shopify orders API.
  • Access token: Uses the shop’s app access token (stored in DB). Token has read_orders scope only — no ability to modify orders.
  • Rate limiting: Shopify Admin API rate limits apply. Each chat turn that includes an order lookup = 1 GraphQL query. Cost is minimal.

  1. Prisma migration — Add metadata to ChatSession; run migration on dev and production.
  2. order-lookup.server.js — Add parseOrderRef, hasOrderIntent; enhance GraphQL query; rewrite formatOrderSummary.
  3. rag.server.js — Replace old regex helper; add session state logic; add clarifying reply path.
  4. prompt-builder.server.js — Update order block system prompt text.
  5. Manual test — In the chat widget on the test store:
    • Ask “where is my order” → verify AI asks for order number.
    • Reply with order: 222 → verify order details appear in reply.
    • Reply with #1001 → verify correct parsing.
    • Reply with an invalid number → verify graceful not-found message.
    • Change topic mid-flow → verify awaitingOrderNumber is cleared.
  6. Widget quick-reply buttons — Add “Track My Order” chip in chat-widget.js.

Use this helper script to test order lookup without going through the storefront chat widget:

  • Script path: scripts/test-order-lookup.mjs

Examples:

Terminal window
node scripts/test-order-lookup.mjs --shop=appifire-ai-chat-test-store.myshopify.com --orderRef=#1001
Terminal window
node scripts/test-order-lookup.mjs --shop=appifire-ai-chat-test-store.myshopify.com --input="My order number is 1001"

Expected output includes:

  • ok, found, error
  • parsed reference (parsedOrderRef)
  • token source (session_table or shops_table)
  • orderName
  • enriched orderSummary from formatOrderSummary()