The operations manager at a $5M DTC brand spends 3+ hours daily manually reviewing return reasons and applying store policy to decide refunds, replacements, or RMAs. This delays processing and frustrates customers who expect instant resolution. The manual process also leads to inconsistent decisions, eroding trust and increasing chargebacks. Margin pressure means every minute of human support cuts into already thin profits.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial walks you through building a Return Reason Agent for Shopify Merchants — a Next.js 16 App Router application that automates refund, replacement, RMA, and escalation decisions from Shopify return-reason webhooks. The agent pipes incoming returns through a guardrail chain, hybrid RAG retriever (Qdrant vector + BM25), context window planner, agnostic LLM via the Vercel AI SDK, and a six-strategy structured repair engine. It then submits the decision back to the Shopify Admin API.
It’s written for TypeScript developers who are comfortable with Next.js and want to see how six @reaatech/* packages wire together into a real ecommerce decision pipeline.
Prerequisites
Node.js >= 22 and pnpm 10 installed on your machine
A Shopify store (development or sandbox) with Admin API access — you’ll need API key, secret, shop domain, and an admin access token
An OpenAI API key for embeddings (text-embedding-3-small) and chat completions (gpt-5.2)
A Qdrant instance (local Docker or cloud) — the RAG pipeline stores hybrid vectors here
A Postgres database with pgvector extension — for storing decisions
Basic familiarity with Next.js App Router, Zod schemas, and Vitest testing
Step 1: Create the project and pin dependencies
Start from an empty directory and create a Next.js 16 App Router project. Then add all REAA packages and third-party dependencies with exact semver pins.
terminal
pnpm init
Edit package.json to add these exact-pinned dependencies:
Add these environment variable placeholders to .env.example:
env
# Env vars used by agnostic-return-reason-agent.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_API_KEY=<your-openai-key>SHOPIFY_API_KEY=<your-shopify-api-key>SHOPIFY_API_SECRET_KEY=<your-shopify-api-secret>SHOPIFY_SHOP=<your-shopify-shop-name>SHOPIFY_ACCESS_TOKEN=<your-shopify-admin-access-token>QDRANT_URL=http://localhost:6333POSTGRES_URL=<your-postgres-connection-string>RERANKER_API_KEY=<your-reranker-api-key>
Expected output:pnpm install exits 0. No ^, ~, or > in any version string.
Step 2: Define the Zod schemas
Three schemas model the data flowing through the pipeline: a return reason coming from Shopify, a store policy rule, and the agent’s final decision.
Create src/schemas/return-reason.ts:
ts
import { z } from "zod";export const ReturnReason = z.object({ orderId: z.string(), productId: z.string(), customerId: z.string(), reason: z.string().min(1), date: z.iso.datetime(),});export type ReturnReasonT = z.infer<typeof ReturnReason>;
Create src/schemas/store-policy.ts:
ts
import { z } from "zod";export const StorePolicy = z.object({ id: z.string(), rule: z.string().min(1), action: z.enum(["refund", "replacement", "rma", "escalate"]), conditions: z.string().optional(),});export type StorePolicyT = z.infer<typeof StorePolicy>;
Create src/schemas/decision.ts:
ts
import { z } from "zod";export const AgentDecision = z.object({ orderId: z.string(), action: z.enum(["refund", "replacement", "rma", "escalate"]), reason: z.string().min(1), confidence: z.number().min(0).max(1), policyCitations: z.array(z.string()),});export type AgentDecisionT = z.infer<typeof AgentDecision>;
Create src/schemas/index.ts to re-export everything:
ts
export { ReturnReason, type ReturnReasonT } from "./return-reason.js";export { StorePolicy, type StorePolicyT } from "./store-policy.js";export { AgentDecision, type AgentDecisionT } from "./decision.js";
Expected output:pnpm typecheck passes. Each schema correctly validates the happy path and rejects invalid inputs.
Step 3: Build the Shopify client
The ShopifyClient wraps the @shopify/shopify-api Node adapter to fetch open return orders, submit decisions, and read store policy from metafields.
Create src/services/shopify-client.ts:
ts
import "@shopify/shopify-api/adapters/node";import { shopifyApi, ApiVersion, Session } from "@shopify/shopify-api";import type { ReturnReasonT } from "../schemas/return-reason.js";import type { StorePolicyT } from "../schemas/store-policy.js";import type { AgentDecisionT } from "../schemas/decision.js";interface ReturnOrderEdge { node: { id: string; name: string; customer: { id: string }; returnLineItems: { edges: Array<{ node
Expected output:pnpm typecheck passes. The class reads all four Shopify env vars from process.env at construction time.
Step 4: Build the RAG ingestion service
The IngestionService uses @reaatech/hybrid-rag-ingestion to validate, preprocess, and chunk store policy text into searchable fragments.
Expected output:pnpm typecheck passes. The pipeline is configured with OpenAI embeddings and hybrid (vector + BM25) search.
Step 6: Build the guardrail service
The GuardrailService chains three guardrails from @reaatech/guardrail-chain-guardrails — PII redaction, prompt injection detection, and toxicity filtering — through a GuardrailChain from @reaatech/guardrail-chain.
Expected output:pnpm typecheck passes. setLogger is called at module level before any class instantiation.
Step 7: Build the context window planner service
The ContextPlannerService uses @reaatech/context-window-planner to pack the LLM’s context window with a system prompt, prioritized RAG chunks, the user query, and reserved output token space. If the budget is exceeded it trims the lowest-relevance chunks and retries.
Expected output:pnpm typecheck passes. A 128K budget with 4K reserved output tokens gives the LLM plenty of room for policy context.
Step 8: Build the structured repair service
The RepairService wraps @reaatech/structured-repair-core to repair malformed LLM output back into a valid AgentDecision. It exposes four operations: repairDecision (throws on failure), analyze (diagnose JSON), validate (boolean check), and repairWithFallback (returns success flag + data safely).
Expected output:pnpm typecheck passes. The repair function runs strip-fences, fix-syntax, coerce-types, and the other strategies.
Step 9: Wire the MCP server (optional)
The @reaatech/structured-repair-mcp package exposes the repair engine as an MCP server over stdio. This is optional — you can start it in instrumentation.ts or leave it for CLI use.
Create src/services/mcp-server.ts:
ts
import { createStructuredRepairServer } from "@reaatech/structured-repair-mcp";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";export async function startMcpServer(): Promise<void> { const server = createStructuredRepairServer(); const transport = new StdioServerTransport(); await server.connect(transport);}
You’ll need a type declaration for the MCP package. Create src/types/reaatech__structured-repair-mcp.d.ts:
ts
declare module "@reaatech/structured-repair-mcp" { import { z } from "zod"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; export function createStructuredRepairServer(): { connect(transport: Transport): Promise<void>; }; export function jsonSchemaToZod(schema: Record<string, unknown>): z.ZodType; export function startServer(): Promise<void>;}
Expected output:pnpm typecheck passes with no module-not-found errors for @reaatech/structured-repair-mcp.
Step 10: Build the Return Reason Agent orchestrator
The ReturnReasonAgent is the central orchestrator. It takes all five services in its constructor and exposes processReturn (single return reason → decision) and batchProcess (multiple reasons, error-survivable).
Create src/services/return-reason-agent.ts:
ts
import { generateText } from "ai";import { openai } from "@ai-sdk/openai";import type { ReturnReasonT } from "../schemas/return-reason.js";import type { AgentDecisionT } from "../schemas/decision.js";import type { ShopifyClient } from "./shopify-client.js";import type { RagService } from "./rag-pipeline.js";import type { GuardrailService } from "./guardrail-service.js";import type { ContextPlannerService } from "./context-planner-service.js";import type { RepairService } from "./repair-service.js";const SYSTEM_PROMPT =
Expected output:pnpm typecheck passes. The agent uses openai("gpt-5.2") as the LLM — swap this to any other AI SDK provider.
Step 11: Create the API route handlers
Three route handlers expose the agent over HTTP.
Create app/api/webhooks/shopify/returns/route.ts — the inbound webhook that triggers a decision:
ts
import { type NextRequest, NextResponse } from "next/server";import { ReturnReasonAgent } from "@/src/services/return-reason-agent.js";import { ShopifyClient } from "@/src/services/shopify-client.js";import { RagService } from "@/src/services/rag-pipeline.js";import { GuardrailService } from "@/src/services/guardrail-service.js";import { ContextPlannerService } from "@/src/services/context-planner-service.js";import { RepairService } from "@/src/services/repair-service.js";import { ReturnReason } from "@/src/schemas/return-reason.js";const shopify = new ShopifyClient();const rag = new RagService();const guardrail = new GuardrailService();const planner = new ContextPlannerService();const repair = new RepairService();const agent = new ReturnReasonAgent({ shopify, rag, guardrail, planner, repair,});export async function POST(req: NextRequest): Promise<NextResponse> { try { const body = await req.json() as Record<string, unknown>; const parsed = ReturnReason.parse(body); const decision = await agent.processReturn(parsed); return NextResponse.json({ decision }); } catch (err) { const message = err instanceof Error ? err.message : "Invalid request body"; return NextResponse.json({ error: message }, { status: 400 }); }}
Create app/api/decisions/route.ts — list recent decisions from Postgres:
ts
import { NextResponse } from "next/server";import { sql } from "@vercel/postgres";export async function GET(): Promise<NextResponse> { try { const { rows } = await sql`SELECT * FROM decisions ORDER BY created_at DESC LIMIT 100`; return NextResponse.json({ decisions: rows }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch decisions"; return NextResponse.json({ error: message }, { status: 500 }); }}
Create app/api/decisions/[id]/route.ts — fetch a single decision by ID (Next 16 async params pattern):
ts
import { type NextRequest, NextResponse } from "next/server";import { sql } from "@vercel/postgres";export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> { try { const { id } = await params; const { rows } = await sql`SELECT * FROM decisions WHERE id = ${id}`; if (rows.length === 0) { return NextResponse.json({ error: "not found" }, { status: 404 }); } return NextResponse.json(rows[0]); } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch decision"; return NextResponse.json({ error: message }, { status: 500 }); }}
Create app/api/policy/upload/route.ts — accept policy text and index it into the RAG store:
ts
import { type NextRequest, NextResponse } from "next/server";import { IngestionService } from "@/src/services/rag-ingestion.js";import { RagService } from "@/src/services/rag-pipeline.js";const ingestion = new IngestionService();const rag = new RagService();export async function POST(req: NextRequest): Promise<NextResponse> { try { const body = await req.json() as Record<string, unknown>; const policyText = body.policyText; const docId = body.docId; if (!policyText || typeof policyText !== "string") { return NextResponse.json( { error: "policyText is required and must be a string" }, { status: 400 } ); } const chunks = await ingestion.ingestPolicy( policyText, typeof docId === "string" ? docId : `policy-${String(Date.now())}` ); await rag.initialize(); await rag.indexDocuments(chunks); return NextResponse.json({ indexed: chunks.length }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to upload policy"; return NextResponse.json({ error: message }, { status: 500 }); }}
Update app/page.tsx with a minimal description:
tsx
export default function Home() { return ( <main> <h1>Return Reason Agent for Shopify Merchants</h1> <p> Automates Shopify return-reason decisions via hybrid RAG, guardrail chain, structured repair, and agnostic LLM. </p> <p> Ingest store policies, process return reason webhooks, and submit decisions (refund, replacement, RMA, escalate) back to Shopify. </p> </main> );}
Expected output:pnpm typecheck passes. Every route handler uses NextRequest/NextResponse from next/server, not bare Request/Response.
Step 12: Set up the public API exports
Replace the placeholder src/index.ts to expose all services and schemas for programmatic use:
ts
export { ReturnReasonAgent } from "./services/return-reason-agent.js";export { ShopifyClient } from "./services/shopify-client.js";export { RagService } from "./services/rag-pipeline.js";export { IngestionService } from "./services/rag-ingestion.js";export { GuardrailService } from "./services/guardrail-service.js";export { ContextPlannerService } from "./services/context-planner-service.js";export { RepairService } from "./services/repair-service.js";export { startMcpServer } from "./services/mcp-server.js";export { ReturnReason, type ReturnReasonT } from "./schemas/return-reason.js";export { StorePolicy, type StorePolicyT } from "./schemas/store-policy.js";export { AgentDecision, type AgentDecisionT } from "./schemas/decision.js";
Expected output:pnpm typecheck passes. All services and types are re-exported from a single entry point.
Step 13: Write the test suite
Set up MSW handlers to mock the Shopify GraphQL API and OpenAI chat completions.
Create tests/setup.ts — configures MSW server lifecycle for all tests:
ts
import { setupServer } from "msw/node";import { beforeAll, afterEach, afterAll } from "vitest";import { shopifyHandlers } from "./mocks/shopify-handlers.js";import { openaiHandlers } from "./mocks/openai-handlers.js";const server = setupServer(...shopifyHandlers, ...openaiHandlers);beforeAll(() => { server.listen({ onUnhandledRequest: "error" }); });afterEach(() => { server.resetHandlers(); });afterAll(() => { server.close(); });export { server };
Now write the tests for each module. The main orchestrator test (tests/services/return-reason-agent.test.ts) covers the full happy path, guardrail block, repair failure, empty RAG results, null-fallback, and batch error-survival:
ts
import { describe, it, expect, vi, beforeEach } from "vitest";import type { ReturnReasonT } from "../../src/schemas/return-reason.js";import type { AgentDecisionT } from "../../src/schemas/decision.js";import type { RetrievalResult } from "@reaatech/hybrid-rag";import { ReturnReasonAgent } from "../../src/services/return-reason-agent.js";import type { ShopifyClient } from "../../src/services/shopify-client.js";import type { RagService } from "../../src/services/rag-pipeline.js";import type { GuardrailService } from "../../src/services/guardrail-service.js";import type { ContextPlannerService } from "../../src/services/context-planner-service.js";import
tests/services/context-planner-service.test.ts — happy pack, budget exceed trim, zero chunks, many chunks
tests/services/repair-service.test.ts — valid JSON, malformed JSON, markdown fences, extra fields
tests/routes/decisions.test.ts — list decisions, get by ID, 404, DB error
tests/routes/webhook-returns.test.ts — valid webhook, malformed body
tests/routes/policy-upload.test.ts — valid upload, missing policy text
Expected output:pnpm vitest run --coverage passes all tests with line/branch/function/statement coverage >= 90% on src/**/*.ts and app/**/route.ts.
Step 14: Run the full quality gate
With all code and tests in place, run the full suite:
terminal
pnpm typecheckpnpm lintpnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: All three commands exit 0. vitest-report.json shows numFailedTests === 0, numTotalTests >= 60, and coverage lines/branches/functions/statements >= 90% on runtime code.
Run the preflight validator:
terminal
node /home/rick/solutions-worker/bin/preflight.js
Expected output: The preflight exits 0 and prints {"ok": true, ...}. This confirms no banned patterns (@ts-ignore, @ts-expect-error, : any, as unknown as), correct version pins, and all scaffolded configs intact.
Next steps
Swap the LLM provider — replace openai("gpt-5.2") with any AI SDK provider (anthropic, google, mistral, etc.) to make the agent truly provider-agnostic
Add a reranker — set rerankerProvider in the RAGPipeline config and add RERANKER_API_KEY to .env for higher-quality retrieval ranking
Deploy to Vercel — the Next.js App Router routes are ready for serverless deployment; wire up Vercel Postgres and Qdrant Cloud for production use
Add a dashboard — build a simple UI that lists recent decisions and lets the ops manager override or review the agent’s output before submission
.map((item) => ("content" in item ? (item as { content: string }).content : ""))
.filter(Boolean)
.join("\n\n");
const prompt = `Return reason: ${sanitized.cleaned}\n\nRelevant policy:\n${includedContent}\n\nClassify the return reason into one of the four actions and return JSON.`;