A Shopify store owner receives dozens of return requests daily, each requiring manual review to determine if the item is defective, wrong size, or buyer's remorse. The owner must then decide whether to refund, replace, or offer store credit, consuming hours of time that could be spent on growth. This manual process is error-prone and inconsistent, leading to customer frustration and lost margin.
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.
A Shopify store owner receives dozens of return requests daily, each requiring manual review to determine if the item is defective, wrong size, or buyer’s remorse. This recipe builds an automated return-reason agent that classifies return reasons using an LLM, enforces per-merchant budgets, and triggers refund, replacement, or store-credit decisions. You’ll use a provider-agnostic LLM client (OpenAI-compatible), the A2A agent-to-agent protocol, an intent classifier from @reaatech/agent-mesh-classifier, a budget engine from @reaatech/agent-budget-engine, and the Shopify Admin API. The agent exposes both a Next.js API route and a Fastify-based A2A server, with optional Langfuse observability.
An OpenAI-compatible LLM provider key (OpenAI, DeepSeek, Groq, etc.)
A Shopify Partner development store with Admin API credentials (API key, secret, offline access token)
(Optional) A Langfuse account for LLM tracing
Step 1: Scaffold the project and install dependencies
Create the project directory and set up a Next.js 16 App Router shell. Copy the package.json below which pins every dependency to an exact version — no ^ or ~ ranges.
Now create the .env.example with every variable the agent will need:
env
# LLM provider (agnostic — any OpenAI-compatible endpoint)LLM_API_KEY=<your-llm-key>LLM_BASE_URL=<https://api.openai.com>LLM_MODEL=<gpt-5.2># Shopify Admin APISHOPIFY_API_KEY=<your-shopify-api-key>SHOPIFY_API_SECRET=<your-shopify-secret>SHOPIFY_SHOP_DOMAIN=<your-store.myshopify.com>SHOPIFY_ACCESS_TOKEN=<your-offline-token># Langfuse (optional — omit to disable tracing)LANGFUSE_PUBLIC_KEY=<your-lf-public-key>LANGFUSE_SECRET_KEY=<your-lf-secret-key>LANGFUSE_HOST=<https://cloud.langfuse.com># A2A serverA2A_SERVER_PORT=<3001>A2A_SERVER_BASE_URL=<http://localhost:3001># Merchant budget (per-shop spending limit in USD)BUDGET_LIMIT_USD=<5.0>BUDGET_SOFT_CAP=<0.8>
Expected output:pnpm install finishes without errors. You have node_modules/ and pnpm-lock.yaml on disk.
Step 2: Define domain types and Zod schemas
Create src/lib/types.ts. This file serves as the central type hub: it re-exports types from @reaatech/agent-mesh and @reaatech/llm-router-core, defines the local SpendStore interface and BudgetScope enum, and declares the ReturnRequest and ReturnDecision schemas that the entire pipeline uses.
ts
import { z } from "zod";import { IncomingRequestSchema, AgentResponseSchema, ClassifierOutputSchema } from "@reaatech/agent-mesh";export { IncomingRequestSchema };export type { IncomingRequest } from "@reaatech/agent-mesh";export { AgentResponseSchema };export type { AgentResponse } from "@reaatech/agent-mesh";export { ClassifierOutputSchema };export type { ClassifierOutput } from "@reaatech/agent-mesh";export type { ContextPacket } from "@reaatech/agent-mesh";export type { SessionStatus } from "@reaatech/agent-mesh";export type { CircuitBreakerState } from "@reaatech/agent-mesh";export { ModelDefinitionSchema } from "@reaatech/llm-router-core";export type { ModelDefinition } from "@reaatech/llm-router-core";export type { ModelCapability } from "@reaatech/llm-router-core";export type { CostTelemetry } from "@reaatech/llm-router-core";export type { RoutingRequest } from "@reaatech/llm-router-core";export enum BudgetScope { Merchant = "merchant", Global = "global",}export interface SpendEntry { scopeKey: string; costUsd: number; inputTokens: number; outputTokens: number;}export interface SpendStore { record(entry: SpendEntry): Promise<void>; getTotal(scopeKey: string): Promise<number>; getAllTotals(): Promise<Map<string, number>>; reset(scopeKey: string): Promise<void>;}export const ReturnRequestSchema = IncomingRequestSchema.extend({ orderId: z.string(), productId: z.string(), reasonText: z.string().min(1), customerNote: z.string().optional(),});export type ReturnRequest = z.infer<typeof ReturnRequestSchema>;export const ReturnDecisionSchema = z.object({ action: z.enum(["refund", "replace", "store_credit", "manual_review"]), confidence: z.number().min(0).max(1), explanation: z.string(), estimatedCostUsd: z.number().nonnegative(),});export type ReturnDecision = z.infer<typeof ReturnDecisionSchema>;
Expected output:npx tsc --noEmit passes without errors on this file alone.
Step 3: Validate environment config with Zod
Create src/lib/config.ts. Rather than scattering process.env reads throughout the application, you’ll parse all environment variables through a single Zod schema at startup. Missing required variables cause an immediate exit with a clear error message.
Expected output: Touch this file and verify it compiles. With LLM_API_KEY, SHOPIFY_SHOP_DOMAIN, and SHOPIFY_ACCESS_TOKEN set in your .env, the config exports a typed object at module load time.
Step 4: Create the LLM client (provider-agnostic)
Create src/lib/llm-client.ts. You’ll use the openai npm package pointed at an arbitrary base URL, making it compatible with OpenAI, DeepSeek, Groq, or any OpenAI-compatible endpoint. The generateDecision function wraps a chat completion call with a JSON output format and a structured system prompt, so the LLM always returns a parsable JSON object with action, explanation, and confidence.
ts
import OpenAI from "openai";import { config } from "./config.js";export function createLlmClient(): OpenAI { return new OpenAI({ baseURL: config.LLM_BASE_URL, apiKey: config.LLM_API_KEY, timeout: 30_000, maxRetries: 2, });}export async function generateDecision(client: OpenAI, prompt: string): Promise<string> { const systemPrompt = 'You are a return reason classifier for Shopify. Analyze the customer\'s return reason and return a JSON object with keys: action ("refund"|"replace"|"store_credit"|"manual_review"), explanation (one sentence), confidence (0.0-1.0).'; try { const completion = await client.chat.completions.create({ model: config.LLM_MODEL, messages: [ { role: "developer", content: systemPrompt }, { role: "user", content: prompt }, ], response_format: { type: "json_object" }, }); return completion.choices[0]?.message?.content ?? "{}"; } catch (error) { if (error instanceof OpenAI.APIError) { throw error; } return "{}"; }}
Expected output: TypeScript compiles without errors. The function uses response_format: { type: "json_object" } to enforce structured JSON output from the model.
Step 5: Build the intent classifier service
Create src/services/classifier-service.ts. This service wraps @reaatech/agent-mesh-classifier to classify a customer’s return reason text into one of four agent categories: refund-agent, replace-agent, store-credit-agent, or escalate-agent. You’ll define an agent registry with descriptions and example phrases for each category. If the classifier hits a rate limit, it falls back to deterministic keyword matching so the system degrades gracefully instead of failing.
Expected output:npx tsc --noEmit passes. The registry has exactly four agents covering the four return-action categories.
Step 6: Build the budget service with InMemorySpendStore
Create src/services/budget-service.ts. This service wraps @reaatech/agent-budget-engine to enforce per-merchant spending limits. You’ll extend the vendored SpendStore base class from @reaatech/agent-budget-spend-tracker with an in-memory implementation. The service exports three main operations: defineMerchantBudget to set a merchant’s limit, preflightCheck to verify a request won’t exceed the budget, and recordSpend to log actual costs after processing.
ts
import { BudgetController } from "@reaatech/agent-budget-engine";import { SpendStore as VendoredSpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetScope as VendoredBudgetScope } from "@reaatech/agent-budget-types";import type { SpendEntry as VendoredSpendEntry } from "@reaatech/agent-budget-types";import type { SpendEntry } from "../lib/types.js";export class InMemorySpendStore extends VendoredSpendStore { private scopeKeys = new Set<string>(); private entriesByKey = new Map<string, SpendEntry[]>();
Expected output: TypeScript compiles. Each async function returns Promise<void> matching the plan’s interface contract.
Step 7: Build the Shopify client
Create src/services/shopify-client.ts. This wrapper configures @shopify/shopify-api with an offline access token and provides three operations: getOrder to fetch order details, createRefund to process a refund through the Admin REST API, and sendOrderNote to attach a decision explanation to the order.
Expected output: The side-effect import import "@shopify/shopify-api/adapters/node" loads the Node.js adapter. The ApiVersion.July25 constant matches the 2025-07 API version in the REST paths.
Step 8: Wire the return reason agent orchestrator
Create src/services/return-reason-agent.ts. This is the central orchestrator that ties together the classifier, budget engine, Shopify client, LLM client, and observability layer. It validates the configured LLM model against ModelDefinitionSchema at module load, then exports processReturnRequest which runs the full pipeline: classify the reason, map it to an action, check the merchant’s budget, and — if the action is refund — call the Shopify API.
ts
import { ModelDefinitionSchema } from "@reaatech/llm-router-core";import type { ClassifierOutput } from "@reaatech/agent-mesh";import { classifyReturnReason } from "./classifier-service.js";import { createBudgetController, defineMerchantBudget, preflightCheck, recordSpend,} from "./budget-service.js";import { createLlmClient, generateDecision } from "../lib/llm-client.js";import { tracedLlmCall, tracedClassification } from "../lib/observability.js";import { createRestClient, createRefund, sendOrderNote } from "./shopify-client.js";import { config } from "../lib/config.js";import type { ReturnDecision } from
Expected output: The file exports processReturnRequest and handleClassification. Every code path that runs the LLM is wrapped in tracedLlmCall. All errors are caught and return a ReturnDecision with action: "manual_review" — the caller is never crashed.
Step 9: Add Langfuse observability
Create src/lib/observability.ts. This module lazily initializes a Langfuse client only when the Langfuse environment variables are present. It exports tracedLlmCall which wraps any async function with a trace + span, recording the output or error. The tracedClassification helper logs classification results as a separate trace.
ts
import { Langfuse } from "langfuse";import { config } from "./config.js";import type { ClassifierOutput } from "./types.js";export function getTraceClient(): Langfuse | null { if (config.LANGFUSE_PUBLIC_KEY && config.LANGFUSE_SECRET_KEY) { return new Langfuse({ publicKey: config.LANGFUSE_PUBLIC_KEY, secretKey: config.LANGFUSE_SECRET_KEY, baseUrl: config.LANGFUSE_HOST, }); } return null;}export async function tracedLlmCall( traceName: string, fn: () => Promise<unknown>,): Promise<unknown> { const client = getTraceClient(); if (!client) { return fn(); } const trace = client.trace({ name: traceName }); const span = trace.span({ name: traceName }); try { const result = await fn(); span.end({ output: result }); return result; } catch (error) { span.end({ level: "ERROR", statusMessage: String(error) }); throw error; }}export function tracedClassification( inputText: string, result: ClassifierOutput,): void { const client = getTraceClient(); if (!client) { return; } const trace = client.trace({ name: "classification" }); const span = trace.span({ name: "classify-return-reason" }); span.end({ input: inputText, output: result });}
Expected output: When LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are missing from the environment, getTraceClient() returns null and the tracing helpers become no-ops.
Step 10: Create the A2A client and server services
Create src/services/a2a-client-service.ts. This wraps @reaatech/a2a-reference-client to send tasks to remote A2A agents, discover agent cards, and stream task events via SSE.
ts
import { A2AClient } from "@reaatech/a2a-reference-client";export function createA2aClient(baseUrl: string): A2AClient { return new A2AClient({ baseUrl, maxRetries: 3, retryDelayMs: 200, agentCardTtlMs: 300_000, });}export async function discoverRemoteAgent(url: string) { return A2AClient.fromCardUrl(url);}export function sendTask( client: A2AClient, messageId: string, role: string, parts: Array<{ kind: string; text: string }>,) { return client.sendMessage({ messageId, role: role as "user" | "agent", parts: parts as Array<{ kind: "text"; text: string }>, });}export async function* streamTask( client: A2AClient, messageId: string, role: string, parts: Array<{ kind: string; text: string }>,) { const generator = client.sendSubscribe({ messageId, role: role as "user" | "agent", parts: parts as Array<{ kind: "text"; text: string }>, }); for await (const event of generator) { yield event; }}
Now create src/services/a2a-server-service.ts. This uses @reaatech/a2a-reference-server to build an A2A-compatible Hono app with an agent card describing the return-reason agent and an executor that delegates incoming tasks to processReturnRequest.
Expected output: The A2A server service exports createA2aApp and the singleton agentExecutor. The executor extracts text parts and data parts from the incoming message, invokes the orchestrator, and publishes the decision via the event bus.
Step 11: Create the Fastify A2A server
Create src/server.ts. This Fastify server mounts the A2A Hono app at all routes, adds a health check endpoint, and provides a Shopify webhook endpoint that receives return requests and delegates to the orchestrator.
Expected output: The Hono-to-Fastify bridge at fastify.all("/*", ...) converts Fastify request/response objects to Web standard Request/Response and back, so the A2A Hono app works transparently.
Step 12: Create Next.js API routes and dashboard
Create app/api/classify/route.ts — the public API endpoint that accepts a return request and returns a ReturnDecision.
ts
import { z } from "zod";import { NextRequest, NextResponse } from "next/server";import { processReturnRequest } from "@/src/services/return-reason-agent.js";import { ReturnRequestSchema } from "@/src/lib/types.js";import type { ReturnDecision } from "@/src/lib/types.js";export async function POST(req: NextRequest): Promise<NextResponse> { const body: Record<string, unknown> = await req.json() as Record<string, unknown>; const parsed = ReturnRequestSchema.safeParse(body); if (!parsed.success) { const details = z.treeifyError(parsed.error); return NextResponse.json({ error: "Invalid request", details }, { status: 400 }); } const { orderId, reasonText, customerNote } = parsed.data; const shopDomain = typeof body.shopDomain === "string" ? body.shopDomain : ""; const decision: ReturnDecision = await processReturnRequest(shopDomain, orderId, reasonText, customerNote); return NextResponse.json(decision);}
Create app/api/health/route.ts — a simple health check.
ts
import { NextResponse } from "next/server";export function GET(): NextResponse { return NextResponse.json({ status: "ok", version: "0.1.0" });}
Now create app/page.tsx — a minimal client-side dashboard with a form that POSTs to /api/classify and displays the JSON response.
Expected output: Both route handlers use NextRequest and NextResponse from next/server — never bare Request or new Response(JSON.stringify(...)). The classify route returns 400 with Zod error details when validation fails.
Step 13: Set up MSW test infrastructure
Create tests/setup.ts with MSW handlers that mock both the OpenAI chat completion endpoint and the Shopify Admin REST API endpoints, so tests run without real network calls.
Create tests/helpers.ts with factory functions that produce typed, valid test data.
ts
import { ReturnRequestSchema, ReturnDecisionSchema } from "../src/lib/types.js";import type { ReturnRequest, ReturnDecision, ClassifierOutput } from "../src/lib/types.js";import { createBudgetController } from "../src/services/budget-service.js";export function makeReturnRequest(overrides?: Partial<ReturnRequest>): ReturnRequest { return ReturnRequestSchema.parse({ orderId: "ord-1", productId: "prod-1", reasonText: "item arrived broken", ...overrides, });}export function makeClassifierOutput(overrides?: Partial<ClassifierOutput>): ClassifierOutput { return { agent_id: "refund-agent", confidence: 0.9, ambiguous: false, detected_language: "en", intent_summary: "Defective item", entities: {}, ...overrides, };}export function makeReturnDecision(overrides?: Partial<ReturnDecision>): ReturnDecision { return ReturnDecisionSchema.parse({ action: "refund", confidence: 0.9, explanation: "Item is defective", estimatedCostUsd: 0.05, ...overrides, });}export function makeDefaultBudgetController() { return createBudgetController();}
Expected output: Vitest picks up setupFiles: ["./tests/setup.ts"] from your vitest config. The MSW server is configured with onUnhandledRequest: "error" so any unmocked HTTP request causes the test to fail.
Step 14: Write and run tests
Create tests/types.test.ts to verify the Zod schemas and factory functions.
Expected output:pnpm typecheck exits 0 with no TypeScript errors. pnpm lint exits 0. pnpm test reports numFailedTests: 0 and numTotalTests >= 25. The coverage report shows lines, branches, functions, and statements all at or above 90%.
Next steps
Add a persistent spend store (PostgreSQL or Redis) instead of the in-memory implementation so budgets survive server restarts
Support additional Shopify webhook topics (order cancellation, fulfillment events) to expand the agent’s decision-making scope
Add a Slack or email notification channel so merchants receive real-time alerts when a return is processed or when budgets are breached
Deploy the A2A server as a separate service with container orchestration (Docker + Kubernetes), connecting it to the Next.js frontend via the A2A protocol
}
function buildAgentEntry(
agent_id: string,
display_name: string,
description: string,
examples: string[],
): AgentRegistryEntry {
return {
agent_id,
display_name,
description,
endpoint: "local://return-reason-agent",
type: "mcp",
is_default: false,
confidence_threshold: 0.5,
clarification_required: false,
examples,
};
}
const agentRegistry: AgentRegistryEntry[] = [
buildAgentEntry(
"refund-agent", "Refund Agent",
"Handles returns for defective, broken, or damaged products requiring a refund",
["item arrived broken", "product is defective", "damaged during shipping", "product stopped working"],
),
buildAgentEntry(
"replace-agent", "Replace Agent",
"Handles returns due to wrong size, fit, color, or variant",
["ordered wrong size", "doesn't fit properly", "received wrong color", "different variant than expected"],
),
buildAgentEntry(
"store-credit-agent", "Store Credit Agent",
"Handles returns due to buyer's remorse, changed mind, or no longer needed",
["changed my mind", "no longer need this item", "buyer's remorse", "don't like it anymore"],
),
buildAgentEntry(
"escalate-agent", "Escalate Agent",
"Handles ambiguous, unclear, or generic complaints that don't fit other categories",
["not satisfied with purchase", "problem with my order", "want to return everything", "customer service issue"],