# I Built an AI Agent That Handles Orders, Refunds & Support Without LangChain

> Source: <https://dev.to/nikhil_thadani_e07f9ce6cd/i-built-an-ai-agent-that-handles-orders-refunds-support-without-langchain-iph>
> Published: 2026-06-29 06:15:58+00:00

Every AI agent tutorial I found did one of two things:

Either 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."

I wanted to understand what an agent actually is at the code level. So I built one from scratch.

Turns out the whole thing is a while loop.

``` js
typescriptwhile (true) {
  const response = await llm(messages);
  if (noToolCalls) break;        // Claude answered — we're done
  await runTools(toolCalls);     // Claude needs data — run the tools
  messages.push(toolResults);    // feed results back, loop again
}
```

That's the agent. Everything else is just well-written tools hanging off it.

What we built

An ecommerce support agent that can:

🔍 Search products by natural language query

📦 Check order status by order ID

📋 Answer return policy questions

🎫 Create support tickets (write action, foreshadows the Command pattern)

The 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.

How tool-calling actually works

This is the part most tutorials gloss over.

When 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:

```
{
  "content": [
    { "type": "text", "text": "Let me check that for you." },
    {
      "type": "tool_use",
      "id": "toolu_01A2bC...",
      "name": "search_products",
      "input": { "query": "wireless earbuds" }
    }
  ],
  "stop_reason": "tool_use"
}
```

Your code filters on type:

```
typescriptconst toolCalls = response.content.filter(
  (block): block is Anthropic.ToolUseBlock => block.type === "tool_use"
);
```

Notice 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.

If toolCalls.length === 0, Claude responded with plain text. That's your exit condition. Loop ends.

Folder structure — organized to scale

src/

├── agent/

│ └── EcommerceAgent.ts # the while(true) loop as a class

├── tools/

│ ├── types.ts # shared Tool type

│ ├── index.ts # registry — the only file that knows all tools

│ ├── searchProducts.ts

│ ├── getOrderStatus.ts

│ └── getReturnPolicy.ts

├── data/

│ ├── products.ts # swap this for Postgres later

│ ├── orders.ts

│ └── policy.ts

└── cli.ts

The 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.

``` js
typescript// tools/index.ts — the registry
export const tools: Tool[] = [
  searchProductsTool,
  getOrderStatusTool,
  getReturnPolicyTool,
];
js
typescript// agent loop — never touches individual tools directly
const tool = this.toolset.find((t) => t.name === call.name);
```

This is the Factory pattern in practice — the registry decides which tool to instantiate, the agent just calls it by name.

The design patterns hiding in plain sight

I didn't set out to implement design patterns. But when you structure this properly they appear naturally:

Strategy — swappable LLM provider

```
typescriptclass EcommerceAgent {
  constructor(
    private readonly client: Anthropic = new Anthropic(),
    private readonly toolset: Tool[] = tools,
    private readonly model: string = "claude-sonnet-4-6"
  ) {}
}
```

Want to swap Anthropic for OpenAI? Change one constructor argument. Nothing else moves.

Factory, tool registry

The 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.

Repository (sort of) — data isolation

typescript// src/data/products.ts

export const products: Product[] = [ ... ];

Today 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.

Command — write actions need special treatment

getOrderStatus is a read. getReturnPolicy is a read. But createSupportTicket mutates something. In production that needs:

Audit logging

Confirmation before execution

Idempotency (don't create two tickets for one click)

That's the Command pattern — wrap write actions as objects with their own validation and logging, not just another function in the tool registry.

CQRS, already naturally split

Your 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.

The one question everyone gets wrong

"Does this need WebSockets or SSE?"

No. Here's why.

The 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:

Client sends ONE request

→ server does the whole while(true) loop internally

→ server sends ONE response back

SSE 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.

What "static data" means for production

In the video we use hardcoded arrays:

``` js
typescript// today
const products = [ { id: "p1", title: "Wireless Earbuds Pro", ... } ];

// production
class PostgresProductRepository implements ProductRepository {
  async search(query: string) {
    const embedding = await embedText(query);
    return vectorDb.query({ vector: embedding, topK: 5 });
  }
}
```

The tool file doesn't change. The import changes. That's it.

That'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.

Full agent loop, the complete picture

```
typescriptexport class EcommerceAgent {
  constructor(
    private readonly client: Anthropic = new Anthropic(),
    private readonly toolset: Tool[] = tools,
    private readonly model: string = "claude-sonnet-4-6"
  ) {}

  async chat(userMessage: string, history: ChatMessage[] = []): Promise<ChatResult> {
    const messages = [...history, { role: "user", content: userMessage }];

    while (true) {
      const response = await this.client.messages.create({
        model: this.model,
        max_tokens: 1024,
        system: SYSTEM_PROMPT,
        messages,
        tools: this.toolset.map(({ execute, ...schema }) => schema),
      });

      const toolCalls = response.content.filter(
        (block): block is Anthropic.ToolUseBlock => block.type === "tool_use"
      );

      if (toolCalls.length === 0) {
        const textBlock = response.content.find(
          (block): block is Anthropic.TextBlock => block.type === "text"
        );
        return { reply: textBlock?.text ?? "", messages };
      }

      messages.push({ role: "assistant", content: response.content });
      messages.push({ role: "user", content: await this.runTools(toolCalls) });
    }
  }

  private async runTool(call: Anthropic.ToolUseBlock) {
    const tool = this.toolset.find((t) => t.name === call.name);
    if (!tool) {
      return {
        type: "tool_result",
        tool_use_id: call.id,
        content: JSON.stringify({ error: `Unknown tool: ${call.name}` }),
        is_error: true,
      };
    }
    try {
      const result = await tool.execute(call.input);
      return { type: "tool_result", tool_use_id: call.id, content: JSON.stringify(result) };
    } catch (err) {
      const message = err instanceof Error ? err.message : "Tool execution failed";
      return {
        type: "tool_result",
        tool_use_id: call.id,
        content: JSON.stringify({ error: message }),
        is_error: true,
      };
    }
  }

  private async runTools(toolCalls: Anthropic.ToolUseBlock[]) {
    return Promise.all(toolCalls.map((call) => this.runTool(call)));
  }
}
```

Stack

Runtime: Node.js + tsx (no build step needed)

LLM: Anthropic SDK — claude-sonnet-4-6

Language: TypeScript (strict mode)

Data: In-memory arrays (production: Postgres + pgvector)

What's next is the production version

This video covers the agent layer. The full production version adds:

(Udemy)

Postgres + pgvector — real semantic product search with embeddings

Redis — conversation history that survives server restarts

Repository pattern — proper data abstraction layer

Command pattern — write actions with audit trails

CQRS — explicit read/write split

RAG pipeline — chunk and embed policy docs for real retrieval

Resources

📂 Full source code: need to add

🎥 YouTube video: [https://youtu.be/rxPrtcl42to](https://youtu.be/rxPrtcl42to)

📖 Anthropic tool-calling docs: [https://docs.anthropic.com/en/docs/tool-use](https://docs.anthropic.com/en/docs/tool-use)
