How to Make an Agent
ON THIS PAGE
A Practical Engineering Guide with Executable Code #
While using off-the-shelf harnesses (Antigravity, Claude Code, Cursor) is ideal for daily engineering, building custom AI agents is essential when your organization requires proprietary workflow automation, specialized security boundaries, or domain-specific tool integrations.
At its core, an AI agent is not magic. An agent is simply an LLM wrapped in an infinite evaluation loop equipped with tools (functions it can invoke), context/memory (state management), and safety guardrails:
Agent = Model + System Prompt + Tools + Context Management + Execution Loop + Guardrails
This chapter provides a step-by-step engineering guide to building production-ready AI agents from scratch using real, executable TypeScript and Node.js.
1. The Core Agent Loop (ReAct Pattern) #
The fundamental pattern for any autonomous agent is the ReAct (Reason + Act) loop. The model receives a goal, decides which tool to call, receives the tool's execution result, and repeats this cycle until the task is complete.
Here is a complete, runnable TypeScript implementation of a standalone AI coding agent built from scratch using Native Node.js and the official OpenAI / Anthropic API format:
import fs from 'fs/promises';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
// 1. Define Tool Interfaces
export interface ToolDefinition {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, { type: string; description: string }>;
required: string[];
};
execute: (args: any) => Promise<string>;
}
// 2. Build Practical Development Tools
export const fileReadTool: ToolDefinition = {
name: 'read_file',
description: 'Read contents of a file at relative path',
parameters: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Relative path to file' }
},
required: ['filePath']
},
async execute({ filePath }) {
try {
const content = await fs.readFile(path.resolve(filePath), 'utf-8');
return content;
} catch (err: any) {
return `Error reading file ${filePath}: ${err.message}`;
}
}
};
export const fileWriteTool: ToolDefinition = {
name: 'write_file',
description: 'Create or overwrite a file with given content',
parameters: {
type: 'object',
properties: {
filePath: { type: 'string', description: 'Relative path to target file' },
content: { type: 'string', description: 'Content to write' }
},
required: ['filePath', 'content']
},
async execute({ filePath, content }) {
try {
await fs.mkdir(path.dirname(path.resolve(filePath)), { recursive: true });
await fs.writeFile(path.resolve(filePath), content, 'utf-8');
return `Successfully wrote to ${filePath}`;
} catch (err: any) {
return `Error writing file ${filePath}: ${err.message}`;
}
}
};
export const bashTool: ToolDefinition = {
name: 'run_bash',
description: 'Execute a shell command in the project directory',
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: 'Shell command to run' }
},
required: ['command']
},
async execute({ command }) {
try {
const { stdout, stderr } = await execAsync(command, { timeout: 30000 });
return stdout || stderr || 'Command executed with no output.';
} catch (err: any) {
return `Command failed: ${err.message}\nOutput: ${err.stdout || ''}\nError: ${err.stderr || ''}`;
}
}
};
// Tool Registry
const toolsRegistry = new Map<string, ToolDefinition>([
[fileReadTool.name, fileReadTool],
[fileWriteTool.name, fileWriteTool],
[bashTool.name, bashTool]
]);
// 3. The Autonomous Agent Execution Engine
export class AutonomousAgent {
private model: string;
private apiKey: string;
private maxIterations: number;
constructor(apiKey: string, model: string = 'gpt-4o', maxIterations: number = 10) {
this.apiKey = apiKey;
this.model = model;
this.maxIterations = maxIterations;
}
async runTask(userGoal: string): Promise<string> {
const messages: any[] = [
{
role: 'system',
content: `You are an autonomous software engineering agent.
You inspect code, write files, run tests, and debug issues.
Always verify your work by running build or test commands before declaring completion.
When you have fully achieved the user's goal, respond with your final summary without calling any more tools.`
},
{ role: 'user', content: userGoal }
];
const formattedTools = Array.from(toolsRegistry.values()).map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.parameters
}
}));
for (let iteration = 1; iteration <= this.maxIterations; iteration++) {
console.log(`\n--- [Agent Loop Iteration ${iteration}/${this.maxIterations}] ---`);
// Call API
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: this.model,
messages,
tools: formattedTools,
tool_choice: 'auto'
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(`API Error: ${JSON.stringify(data)}`);
}
const choice = data.choices[0];
const message = choice.message;
messages.push(message);
// Check if model decided to call tools
if (message.tool_calls && message.tool_calls.length > 0) {
for (const toolCall of message.tool_calls) {
const fnName = toolCall.function.name;
const fnArgs = JSON.parse(toolCall.function.arguments);
console.log(`[Tool Call]: ${fnName}(${JSON.stringify(fnArgs)})`);
const tool = toolsRegistry.get(fnName);
let resultText = '';
if (tool) {
resultText = await tool.execute(fnArgs);
} else {
resultText = `Unknown tool: ${fnName}`;
}
console.log(`[Tool Result]: ${resultText.slice(0, 150)}...`);
// Append tool execution response to conversation history
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: resultText
});
}
} else {
// Model chose not to call any tools; task completed
console.log(`\n✅ Task Complete.`);
return message.content || 'Task finished successfully.';
}
}
return 'Agent reached maximum iteration limit without finishing.';
}
}
2. Standardizing Tools with Model Context Protocol (MCP) #
Hardcoding tools directly inside an agent application creates tightly coupled, non-portable code. The modern approach is to build MCP Servers that expose tools via standard JSON-RPC protocol over stdio or Server-Sent Events (SSE). Any MCP-compliant agent harness (Antigravity, Claude Code, Cursor, Cline) can connect to your server automatically.
Here is a complete, runnable TypeScript implementation of an MCP Tool Server using @modelcontextprotocol/sdk:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
// Create an enterprise MCP Server
const server = new Server(
{ name: 'enterprise-db-mcp', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// 1. Define available tools schema
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'query_user_audit_log',
description: 'Queries audit log events for a given user ID',
inputSchema: {
type: 'object',
properties: {
userId: { type: 'string', description: 'User UUID' },
limit: { type: 'number', description: 'Max events to return (default 10)' }
},
required: ['userId']
}
}
]
};
});
// 2. Handle tool execution requests
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'query_user_audit_log') {
const userId = String(args?.userId);
const limit = Number(args?.limit || 10);
// Mock query logic or DB call
const mockLogs = [
{ timestamp: new Date().toISOString(), event: 'MFA_VERIFY_SUCCESS', userId },
{ timestamp: new Date().toISOString(), event: 'PASSWORD_CHANGED', userId }
].slice(0, limit);
return {
content: [
{
type: 'text',
text: JSON.stringify(mockLogs, null, 2)
}
]
};
}
throw new Error(`Tool not found: ${name}`);
});
// Start transport over STDIO
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch(console.error);
3. Multi-Agent Systems: Manager + Worker Pattern #
For complex engineering tasks—such as implementing a feature end-to-end—a single agent loop can easily overwhelm its context window. The solution is a Multi-Agent Manager + Worker Architecture:
- Manager Agent: Analyzes requirements, breaks work into subtasks, delegates each subtask to a specialized Worker Agent, and evaluates the final combined output.
- Worker Agents: Focused, single-purpose agents (e.g., Code Developer Worker, Test Writer Worker, Security Reviewer Worker) operating in isolated sub-conversations with compact context windows.
// Multi-Agent Coordinator Example
export class MultiAgentCoordinator {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async executeFeature(featureSpec: string): Promise<void> {
console.log('[Manager] Step 1: Planning implementation architecture...');
const plan = await this.managerPlan(featureSpec);
console.log('[Worker 1] Step 2: Implementing code changes...');
const developerAgent = new AutonomousAgent(this.apiKey, 'gpt-4o', 15);
await developerAgent.runTask(`Implement the code according to this plan:\n${plan}`);
console.log('[Worker 2] Step 3: Generating unit test suite...');
const testAgent = new AutonomousAgent(this.apiKey, 'gpt-4o', 10);
await testAgent.runTask(`Write unit tests for the newly created modules described in:\n${plan}`);
console.log('[Manager] Step 4: Final verification pass...');
const verifierAgent = new AutonomousAgent(this.apiKey, 'gpt-4o', 5);
await verifierAgent.runTask(`Run the test suite using run_bash "npm test" and report final status.`);
}
private async managerPlan(spec: string): Promise<string> {
// High-level planning call
const agent = new AutonomousAgent(this.apiKey, 'gpt-4o', 3);
return await agent.runTask(`Create an architectural step-by-step implementation plan for: ${spec}`);
}
}
4. Production Hardening & Safety Rules #
When building custom agents, enforce these mandatory guardrails to prevent runaway execution or security incidents:
- Strict Loop Counter (Max Iterations): Always cap agent loop iterations (
maxIterations = 10-20). If an agent fails to complete the task within the limit, break the loop and alert the user. - Context Window Trimming: As conversation history grows, summarize or prune early tool outputs (
role: 'tool'). Tool responses (like build output logs or file reads) can consume tens of thousands of tokens; truncate long outputs before appending to history. - Destructive Command Gate: Intercept command strings in your
run_bashtool execution handler. Block or require human approval for commands containingrm -rf,git push --force,drop database, orsudo. - Structured Output Verification: Never assume an agent's code edit worked simply because the model said so. Require the agent loop to run verification tools (
npm test,tsc --noEmit,pytest) before returning success.