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.
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.
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:
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
π Anthropic tool-calling docs: https://docs.anthropic.com/en/docs/tool-use