{"slug": "i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain", "title": "I Built an AI Agent That Handles Orders, Refunds & Support Without LangChain", "summary": "A developer built an ecommerce AI agent from scratch without LangChain, revealing the core logic is a simple while loop that calls an LLM and executes tools until no tool calls are needed. The agent handles orders, refunds, and support by using Anthropic's tool-calling API with a clean folder structure and design patterns like Strategy and Factory.", "body_md": "Every AI agent tutorial I found did one of two things:\n\nEither it used LangChain, which abstracts away the exact thing you need to understand or it was so simple it was basically a chatbot with if/else routing and called itself an \"agent.\"\n\nI wanted to understand what an agent actually is at the code level. So I built one from scratch.\n\nTurns out the whole thing is a while loop.\n\n``` js\ntypescriptwhile (true) {\n  const response = await llm(messages);\n  if (noToolCalls) break;        // Claude answered — we're done\n  await runTools(toolCalls);     // Claude needs data — run the tools\n  messages.push(toolResults);    // feed results back, loop again\n}\n```\n\nThat's the agent. Everything else is just well-written tools hanging off it.\n\nWhat we built\n\nAn ecommerce support agent that can:\n\n🔍 Search products by natural language query\n\n📦 Check order status by order ID\n\n📋 Answer return policy questions\n\n🎫 Create support tickets (write action, foreshadows the Command pattern)\n\nThe agent figures out on its own which tool to call — sometimes multiple tools in a single message. You never write if (message.includes(\"order\")) anywhere.\n\nHow tool-calling actually works\n\nThis is the part most tutorials gloss over.\n\nWhen you pass tools to anthropic.messages.create(), Claude's response isn't just text but it's an array of content blocks, each tagged with a type:\n\n```\n{\n  \"content\": [\n    { \"type\": \"text\", \"text\": \"Let me check that for you.\" },\n    {\n      \"type\": \"tool_use\",\n      \"id\": \"toolu_01A2bC...\",\n      \"name\": \"search_products\",\n      \"input\": { \"query\": \"wireless earbuds\" }\n    }\n  ],\n  \"stop_reason\": \"tool_use\"\n}\n```\n\nYour code filters on type:\n\n```\ntypescriptconst toolCalls = response.content.filter(\n  (block): block is Anthropic.ToolUseBlock => block.type === \"tool_use\"\n);\n```\n\nNotice the TypeScript type guard — (block): block is Anthropic.ToolUseBlock. This isn't just a runtime check. It tells TypeScript to narrow the type so call.name and call.input are properly typed, not any. The SDK exports ContentBlock = TextBlock | ToolUseBlock as a proper discriminated union — use it.\n\nIf toolCalls.length === 0, Claude responded with plain text. That's your exit condition. Loop ends.\n\nFolder structure — organized to scale\n\nsrc/\n\n├── agent/\n\n│ └── EcommerceAgent.ts # the while(true) loop as a class\n\n├── tools/\n\n│ ├── types.ts # shared Tool type\n\n│ ├── index.ts # registry — the only file that knows all tools\n\n│ ├── searchProducts.ts\n\n│ ├── getOrderStatus.ts\n\n│ └── getReturnPolicy.ts\n\n├── data/\n\n│ ├── products.ts # swap this for Postgres later\n\n│ ├── orders.ts\n\n│ └── policy.ts\n\n└── cli.ts\n\nThe key decision: each tool in its own file. Adding a new tool means one new file + one line in tools/index.ts. The agent loop never changes.\n\n``` js\ntypescript// tools/index.ts — the registry\nexport const tools: Tool[] = [\n  searchProductsTool,\n  getOrderStatusTool,\n  getReturnPolicyTool,\n];\njs\ntypescript// agent loop — never touches individual tools directly\nconst tool = this.toolset.find((t) => t.name === call.name);\n```\n\nThis is the Factory pattern in practice — the registry decides which tool to instantiate, the agent just calls it by name.\n\nThe design patterns hiding in plain sight\n\nI didn't set out to implement design patterns. But when you structure this properly they appear naturally:\n\nStrategy — swappable LLM provider\n\n```\ntypescriptclass EcommerceAgent {\n  constructor(\n    private readonly client: Anthropic = new Anthropic(),\n    private readonly toolset: Tool[] = tools,\n    private readonly model: string = \"claude-sonnet-4-6\"\n  ) {}\n}\n```\n\nWant to swap Anthropic for OpenAI? Change one constructor argument. Nothing else moves.\n\nFactory, tool registry\n\nThe tools/index.ts file is your factory. The agent never does new SearchProductsTool() — it looks up by name. Adding a tool is additive, not a modification.\n\nRepository (sort of) — data isolation\n\ntypescript// src/data/products.ts\n\nexport const products: Product[] = [ ... ];\n\nToday it's an array. In production it becomes a Postgres query. The tool files that import from data/ don't change — only the data file itself changes. That's the Repository pattern's whole point.\n\nCommand — write actions need special treatment\n\ngetOrderStatus is a read. getReturnPolicy is a read. But createSupportTicket mutates something. In production that needs:\n\nAudit logging\n\nConfirmation before execution\n\nIdempotency (don't create two tickets for one click)\n\nThat's the Command pattern — wrap write actions as objects with their own validation and logging, not just another function in the tool registry.\n\nCQRS, already naturally split\n\nYour read tools (search, status, policy) hit one data source. Your write tools (create ticket) hit another path entirely. The split is already there — CQRS just makes it intentional and explicit.\n\nThe one question everyone gets wrong\n\n\"Does this need WebSockets or SSE?\"\n\nNo. Here's why.\n\nThe agent loop runs entirely server-side. Multiple Anthropic API calls, tool executions, result feeding — all of it happens inside one async function. From the client's perspective:\n\nClient sends ONE request\n\n→ server does the whole while(true) loop internally\n\n→ server sends ONE response back\n\nSSE is a UX upgrade (stream tokens as they arrive so users don't stare at a blank screen for 3 seconds), not a technical requirement. The agent works perfectly fine as a standard request/response without it.\n\nWhat \"static data\" means for production\n\nIn the video we use hardcoded arrays:\n\n``` js\ntypescript// today\nconst products = [ { id: \"p1\", title: \"Wireless Earbuds Pro\", ... } ];\n\n// production\nclass PostgresProductRepository implements ProductRepository {\n  async search(query: string) {\n    const embedding = await embedText(query);\n    return vectorDb.query({ vector: embedding, topK: 5 });\n  }\n}\n```\n\nThe tool file doesn't change. The import changes. That's it.\n\nThat's what \"basic but scalable\" actually means — not that the simple version is production-ready, but that the seam where you'd upgrade is visible and clean.\n\nFull agent loop, the complete picture\n\n```\ntypescriptexport class EcommerceAgent {\n  constructor(\n    private readonly client: Anthropic = new Anthropic(),\n    private readonly toolset: Tool[] = tools,\n    private readonly model: string = \"claude-sonnet-4-6\"\n  ) {}\n\n  async chat(userMessage: string, history: ChatMessage[] = []): Promise<ChatResult> {\n    const messages = [...history, { role: \"user\", content: userMessage }];\n\n    while (true) {\n      const response = await this.client.messages.create({\n        model: this.model,\n        max_tokens: 1024,\n        system: SYSTEM_PROMPT,\n        messages,\n        tools: this.toolset.map(({ execute, ...schema }) => schema),\n      });\n\n      const toolCalls = response.content.filter(\n        (block): block is Anthropic.ToolUseBlock => block.type === \"tool_use\"\n      );\n\n      if (toolCalls.length === 0) {\n        const textBlock = response.content.find(\n          (block): block is Anthropic.TextBlock => block.type === \"text\"\n        );\n        return { reply: textBlock?.text ?? \"\", messages };\n      }\n\n      messages.push({ role: \"assistant\", content: response.content });\n      messages.push({ role: \"user\", content: await this.runTools(toolCalls) });\n    }\n  }\n\n  private async runTool(call: Anthropic.ToolUseBlock) {\n    const tool = this.toolset.find((t) => t.name === call.name);\n    if (!tool) {\n      return {\n        type: \"tool_result\",\n        tool_use_id: call.id,\n        content: JSON.stringify({ error: `Unknown tool: ${call.name}` }),\n        is_error: true,\n      };\n    }\n    try {\n      const result = await tool.execute(call.input);\n      return { type: \"tool_result\", tool_use_id: call.id, content: JSON.stringify(result) };\n    } catch (err) {\n      const message = err instanceof Error ? err.message : \"Tool execution failed\";\n      return {\n        type: \"tool_result\",\n        tool_use_id: call.id,\n        content: JSON.stringify({ error: message }),\n        is_error: true,\n      };\n    }\n  }\n\n  private async runTools(toolCalls: Anthropic.ToolUseBlock[]) {\n    return Promise.all(toolCalls.map((call) => this.runTool(call)));\n  }\n}\n```\n\nStack\n\nRuntime: Node.js + tsx (no build step needed)\n\nLLM: Anthropic SDK — claude-sonnet-4-6\n\nLanguage: TypeScript (strict mode)\n\nData: In-memory arrays (production: Postgres + pgvector)\n\nWhat's next is the production version\n\nThis video covers the agent layer. The full production version adds:\n\n(Udemy)\n\nPostgres + pgvector — real semantic product search with embeddings\n\nRedis — conversation history that survives server restarts\n\nRepository pattern — proper data abstraction layer\n\nCommand pattern — write actions with audit trails\n\nCQRS — explicit read/write split\n\nRAG pipeline — chunk and embed policy docs for real retrieval\n\nResources\n\n📂 Full source code: need to add\n\n🎥 YouTube video: [https://youtu.be/rxPrtcl42to](https://youtu.be/rxPrtcl42to)\n\n📖 Anthropic tool-calling docs: [https://docs.anthropic.com/en/docs/tool-use](https://docs.anthropic.com/en/docs/tool-use)", "url": "https://wpnews.pro/news/i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain", "canonical_source": "https://dev.to/nikhil_thadani_e07f9ce6cd/i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain-iph", "published_at": "2026-06-29 06:15:58+00:00", "updated_at": "2026-06-29 06:27:02.170644+00:00", "lang": "en", "topics": ["ai-agents", "large-language-models", "developer-tools", "artificial-intelligence"], "entities": ["Anthropic", "Claude", "LangChain", "TypeScript"], "alternates": {"html": "https://wpnews.pro/news/i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain", "markdown": "https://wpnews.pro/news/i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain.md", "text": "https://wpnews.pro/news/i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain.txt", "jsonld": "https://wpnews.pro/news/i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain.jsonld"}}