Building an AI agent sounds complicated. It's not. By the end of this guide, you'll have a working agent that can search the web, remember conversations, and handle multi-step tasks. No frameworks, just TypeScript and an LLM API.
What We're Building
A research assistant agent that:
- Takes questions from users
- Uses tools (web search) when needed
- Remembers conversation history
- Handles errors without crashing
- Runs in about 150 lines of TypeScript
This won't be production-ready, but it'll work and you'll understand every line.
Prerequisites
You need:
- Node.js 18 or higher
- Basic TypeScript knowledge
- An Anthropic API key (get one free)
That's it. No prior AI experience needed.
Setup (5 minutes)
# Create project
mkdir research-agent
cd research-agent
npm init -y
# Install dependencies
npm install @anthropic-ai/sdk dotenv
# Install dev dependencies
npm install -D typescript @types/node tsx
# Initialize TypeScript
npx tsc --init
Create .env:
ANTHROPIC_API_KEY=your-key-here
Step 1: Define Your Types
Create src/types.ts:
export interface Message {
role: 'user' | 'assistant';
content: string;
}
export interface Tool {
name: string;
description: string;
input_schema: {
type: 'object';
properties: Record<string, any>;
required?: string[];
};
execute: (input: any) => Promise<string>;
}
Why these types matter:
Strong typing prevents bugs. If you change how a tool works, TypeScript tells you everywhere that breaks.
Step 2: Create a Simple Tool
Create src/tools/search.ts:
import { Tool } from '../types';
export const searchTool: Tool = {
name: 'search_web',
description: 'Search the internet for current information. Use this when you need facts, recent events, or data you do not know.',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query',
},
},
required: ['query'],
},
execute: async (input: { query: string }) => {
console.log(`[Tool] Searching for: ${input.query}`);
// For now, return mock data
// In production, call a real search API (SerpAPI, Brave Search, etc.)
return `Search results for "${input.query}":
1. Recent advances in AI agents show improved reasoning capabilities
2. Multi-step task completion is now standard in 2026
3. Tool-calling interfaces have become more reliable
(This is mock data - replace with real search API)`;
},
};
Real-world note:
For production, integrate with SerpAPI, Brave Search API, or Tavily. The structure stays the same.
Step 3: Build the Agent Runner
Create src/agent.ts:
import Anthropic from '@anthropic-ai/sdk';
import { Message, Tool } from './types';
export class Agent {
private client: Anthropic;
private conversationHistory: Message[] = [];
constructor(
private systemPrompt: string,
private tools: Tool[],
private model: string = 'claude-3-5-sonnet-20241022'
) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error('ANTHROPIC_API_KEY not found in environment');
}
this.client = new Anthropic({ apiKey });
}
async chat(userMessage: string): Promise<string> {
// Add user message to history
this.conversationHistory.push({
role: 'user',
content: userMessage,
});
// Create the API request
const response = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: this.systemPrompt,
messages: this.conversationHistory,
tools: this.tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
})),
});
// Check if agent wants to use tools
if (response.stop_reason === 'tool_use') {
return await this.handleToolUse(response);
}
// No tools needed - return direct response
const content = response.content.find(block => block.type === 'text');
const assistantMessage = content?.type === 'text' ? content.text : '';
this.conversationHistory.push({
role: 'assistant',
content: assistantMessage,
});
return assistantMessage;
}
private async handleToolUse(response: any): Promise<string> {
const toolUseBlocks = response.content.filter(
(block: any) => block.type === 'tool_use'
);
// Execute all tool calls
const toolResults = await Promise.all(
toolUseBlocks.map(async (block: any) => {
const tool = this.tools.find(t => t.name === block.name);
if (!tool) {
return {
type: 'tool_result',
tool_use_id: block.id,
content: `Error: Tool ${block.name} not found`,
};
}
try {
const result = await tool.execute(block.input);
return {
type: 'tool_result',
tool_use_id: block.id,
content: result,
};
} catch (error) {
return {
type: 'tool_result',
tool_use_id: block.id,
content: `Error executing tool: ${error}`,
is_error: true,
};
}
})
);
// Add assistant's tool use to history
this.conversationHistory.push({
role: 'assistant',
content: response.content,
});
// Send tool results back to Claude
const followUpResponse = await this.client.messages.create({
model: this.model,
max_tokens: 4096,
system: this.systemPrompt,
messages: [
...this.conversationHistory,
{
role: 'user',
content: toolResults,
},
],
tools: this.tools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
})),
});
// Extract final response
const textBlock = followUpResponse.content.find(
block => block.type === 'text'
);
const finalMessage = textBlock?.type === 'text' ? textBlock.text : '';
this.conversationHistory.push({
role: 'assistant',
content: finalMessage,
});
return finalMessage;
}
getHistory(): Message[] {
return this.conversationHistory;
}
clearHistory(): void {
this.conversationHistory = [];
}
}
What's happening here:
- Constructor: Sets up the Anthropic client and stores tools
- chat(): Main method - sends message, handles response
- handleToolUse(): Executes tools when Claude requests them
- History management: Keeps conversation context between turns
Step 4: Put It All Together
Create src/index.ts:
import { config } from 'dotenv';
import { Agent } from './agent';
import { searchTool } from './tools/search';
// Load environment variables
config();
const systemPrompt = `You are a helpful research assistant.
When users ask questions:
1. If you need current information, use the search_web tool
2. Provide clear, concise answers
3. Cite your sources when using search results
4. If you're unsure, say so - don't guess
Keep responses focused and practical.`;
async function main() {
const agent = new Agent(systemPrompt, [searchTool]);
console.log('Research Agent started. Ask me anything!\n');
// Example conversation
try {
const response1 = await agent.chat(
'What are the main AI trends in 2026?'
);
console.log('Agent:', response1);
console.log('\n---\n');
const response2 = await agent.chat(
'Can you elaborate on the first trend?'
);
console.log('Agent:', response2);
console.log('\n---\n');
// Show conversation history
console.log('Conversation history:');
console.log(JSON.stringify(agent.getHistory(), null, 2));
} catch (error) {
console.error('Error:', error);
}
}
main();
Step 5: Run Your Agent
npx tsx src/index.ts
You should see:
- The agent receives your question
- It decides to use the search tool
- The search executes
- The agent synthesizes a response
- Follow-up questions use conversation history
What Just Happened?
You built a functional AI agent with:
Memory:
The conversationHistory array maintains context across turns. The agent remembers what you've discussed.
Tool Use:
When Claude needs information it doesn't have, it calls the search tool. You can add more tools the same way.
Error Handling:
Tool failures return error messages instead of crashing. The agent can explain what went wrong.
Type Safety:
TypeScript ensures tools match expected interfaces. If you change a tool's signature, TypeScript flags every usage.
Making It Interactive
Want a CLI instead of hardcoded messages? Add this to src/index.ts:
import * as readline from 'readline';
async function interactiveMode() {
const agent = new Agent(systemPrompt, [searchTool]);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log('Research Agent (type "exit" to quit)\n');
const askQuestion = () => {
rl.question('You: ', async (input) => {
if (input.toLowerCase() === 'exit') {
rl.close();
return;
}
try {
const response = await agent.chat(input);
console.log(`\nAgent: ${response}\n`);
} catch (error) {
console.error('Error:', error);
}
askQuestion();
});
};
askQuestion();
}
interactiveMode();
Now you can have real conversations with your agent.
Adding More Tools
Want to add database access, calculations, or API calls? Follow the same pattern:
// src/tools/calculator.ts
import { Tool } from '../types';
export const calculatorTool: Tool = {
name: 'calculate',
description: 'Perform mathematical calculations',
input_schema: {
type: 'object',
properties: {
expression: {
type: 'string',
description: 'Math expression to evaluate (e.g., "2 + 2", "sqrt(16)")',
},
},
required: ['expression'],
},
execute: async (input: { expression: string }) => {
try {
// In production, use a safe math library like mathjs
// This is just for demonstration
const result = eval(input.expression);
return `Result: ${result}`;
} catch (error) {
return `Error calculating: ${error}`;
}
},
};
Add it to your agent:
const agent = new Agent(systemPrompt, [searchTool, calculatorTool]);
That's it. The agent now has math capabilities.
Common Issues
"API key not found"
Make sure .env is in your project root and contains ANTHROPIC_API_KEY=...
"Module not found"
Run npm install to ensure all dependencies are installed.
Agent doesn't use tools
Your system prompt needs to encourage tool use. Try: "When you need information, always use available tools rather than guessing."
Conversation history grows too large
Add a limit:
if (this.conversationHistory.length > 20) {
this.conversationHistory = this.conversationHistory.slice(-10);
}
What's Missing?
This agent is functional but basic. For production, you'd add:
Budget limits:
Track token usage and stop when limits are reached.
Better memory:
Summarize old conversations or use vector storage for semantic recall.
Observability:
Log tool executions, track errors, measure response times.
Multiple agents:
Coordinate specialist agents for complex tasks.
Safety guardrails:
Validate tool inputs, sanitize outputs, handle malicious prompts.
But none of that matters until you've built something that works. Start here, then add complexity as needed.
Next Steps
Improve the search tool:
Replace mock data with a real search API. SerpAPI offers free tiers.
Add more tools:
Database queries, file operations, API integrations - all follow the same pattern.
Build a UI:
Connect this to a web interface using Next.js or a CLI using inquirer.
Deploy it:
This runs anywhere Node.js runs - Vercel, Railway, AWS Lambda.
Study tool calling:
Read Anthropic's tool use guide for advanced patterns.
Wrapping Up
You now have a working AI agent. The code is straightforward:
- Define tools with a consistent interface
- Let the LLM decide when to use them
- Execute tools and send results back
- Maintain conversation history for context
This pattern scales. Production agents are variations of this basic structure with more tools, better error handling, and monitoring.
The hard part isn't the code - it's designing good tools and prompts. But you can only learn that by building and iterating.
Start with this foundation and expand as you learn what your agent needs.
Questions? Drop a comment below.
Top comments (1)
This is a great way to make AI agents feel less intimidating. Building it from scratch in a simple way really helps people understand whatβs actually going on instead of treating it like a black box.