AI agents are everywhere. They book your flights, draft your emails, and answer customer questions at 3 a.m. Yet for most developers, the inner workings of these systems remain a black box. You call an API, pass in a prompt, and receive something that looks intelligent. But what happens in between? How does the system decide which tool to call? How does it remember what you said five messages ago?
Frameworks like LangChain, CrewAI, and AutoGen abstract away these details. They're powerful, but they're also thick layers of indirection. If you've ever tried to debug a multi-agent workflow and found yourself tracing through 15 layers of orchestration code, you know the pain. The abstraction that makes these frameworks easy to use also makes them hard to understand.
Enter SaladDay/pi-from-scratch. This GitHub repository implements a minimal personal AI agent in approximately 600 lines of TypeScript. That's it—one file, no heavy framework dependencies, and a clear, readable implementation of the core concepts that power every AI agent: tool use, memory, and the agent loop.
The repository description reads: "600 行 TypeScript 写成的超级迷你版 pi,让你轻松从 0 写出属于你的 pi-agent"—roughly translated: "A super-mini pi written in 600 lines of TypeScript, letting you easily write your own pi-agent from zero." The name "pi" likely nods to "personal intelligence," though the mathematical constant pun is hard to ignore.
This isn't a production-ready system. It's a teaching tool designed to show you exactly how an agent works by stripping away everything non-essential.
You should read this if:
You don't need to be a TypeScript expert, but basic familiarity with JavaScript or TypeScript will help. You'll also need an OpenAI API key if you want to run the code, though reading the implementation is valuable even without executing it.
By the end of this article, you'll understand:
Let's dive in.
Every AI agent—regardless of complexity—has four fundamental parts:
Frameworks add layers of abstraction on top of these. pi-from-scratch shows you the bare minimum.
The agent loop is deceptively simple:
This is the "while loop" at the heart of every agent. The LLM doesn't just generate text—it generates structured output that tells the agent whether to call a tool or return a final answer.
In a minimal agent, the LLM does two things:
Modern LLM APIs (like OpenAI's) support function calling, where you provide a list of available functions with their JSON schemas. The model can then output a structured request to call one of those functions, rather than just freeform text.
pi-from-scratch leverages this directly. No orchestration framework—just a direct API call with function definitions.
Tools are the agent's way of interacting with the world. In pi-from-scratch, a tool is simply a JavaScript function with a name, a description, and a JSON schema for its parameters.
The agent's LLM sees the tool definitions as part of its context. When the user asks "What's 25 * 17?" the model doesn't compute the answer—it emits a structured request to call the calculator tool with the arguments {expression: "25 * 17"}. Your code executes that function and feeds the result back to the model.
Memory in a minimal agent is just an array of messages. Each turn, you append the user's input and the assistant's response to this array, then send the whole thing to the LLM.
This is the simplest possible memory system. It doesn't persist across sessions, and it doesn't summarize or compress. But it works, and it demonstrates the concept clearly.
The beauty of pi-from-scratch lies in its simplicity. The core agent logic lives in a single file—usually agent.ts or similar. The repository structure looks something like this:
pi-from-scratch/
├── src/
│ ├── agent.ts # Core agent logic
│ ├── tools.ts # Tool definitions and implementations
│ └── index.ts # Entry point / CLI
├── package.json
├── tsconfig.json
└── README.md
The entire agent fits in one file because it doesn't need much. The LLM does the heavy lifting; your code just orchestrates.
The dependency footprint is tiny. You'll need:
openai (or your chosen LLM provider's SDK)dotenv for loading the API key from a .env filetsx or ts-node for running the codeThat's it. No framework dependencies, no complex configuration.
The agent is a class with a few key properties:
class Agent {
private model: string;
private messages: Message[];
private tools: Tool[];
constructor(config: { model: string; apiKey: string; tools: Tool[] }) {
this.model = config.model;
this.tools = config.tools;
this.messages = [];
// Initialize OpenAI client
}
}
The messages array serves as memory. The tools array holds the available functions. The model string specifies which LLM to use (e.g., gpt-4o-mini for cost efficiency).
The core loop is deceptively simple:
async function run(userInput: string): Promise<string> {
this.messages.push({ role: "user", content: userInput });
while (true) {
const response = await this.callLLM();
const message = response.choices[0].message;
if (message.tool_calls) {
// Execute tools and append results
for (const toolCall of message.tool_calls) {
const result = this.executeTool(toolCall);
this.messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result
});
}
} else {
// Final response
this.messages.push(message);
return message.content;
}
}
}
This while (true) loop is the entire agent. It keeps calling the LLM, executing any requested tools, and feeding results back until the model produces a final answer without tool calls.
Tools are defined as objects with a name, description, and parameter schema:
const calculatorTool = {
name: "calculator",
description: "Evaluate a mathematical expression",
parameters: {
type: "object",
properties: {
expression: {
type: "string",
description: "The math expression to evaluate, e.g., '25 * 17'"
}
}
}
};
When the LLM requests a tool call, you execute the corresponding function:
async function executeTool(toolCall: ToolCall): Promise<string> {
const { name, arguments: args } = toolCall.function;
const parsedArgs = JSON.parse(args);
switch (name) {
case "calculator":
return String(eval(parsedArgs.expression));
case "web_search":
return await searchWeb(parsedArgs.query);
// ... other tools
default:
throw new Error(`Unknown tool: ${name}`);
}
}
The eval for the calculator is fine for a demo, but don't use it in production—more on that later.
Memory is simply the messages array. Each iteration of the loop pushes new messages, and the entire history gets sent to the LLM on every call.
This approach has a clear limitation: context window size. After enough turns, you'll hit the model's token limit. That's why production agents use summarization or sliding windows. But for learning, this simplicity is perfect.
pi-from-scratch keeps error handling minimal but present:
JSON.parse.The philosophy is simple: don't crash, just feed the error back to the model and let it recover.
The simplest tool. It takes a string expression and evaluates it:
User: What's 25 * 17?
Agent (LLM reasoning): I need to use the calculator tool.
Agent (tool call): calculator(expression: "25 * 17")
Tool result: "425"
Agent (final response): 25 * 17 = 425.
A search tool that hits a search API (like SerpAPI or Brave Search) and returns the top results:
User: Who won the 2024 World Series?
Agent (tool call): web_search(query: "2024 World Series winner")
Tool result: "The Los Angeles Dodgers defeated the New York Yankees in five games."
Agent (final response): The Dodgers won in five games.
A support bot that queries a database of FAQs:
User: What's your return policy?
Agent (tool call): knowledge_base(query: "return policy")
Tool result: "Returns accepted within 30 days with receipt."
Agent (final response): We accept returns within 30 days...
A personal assistant that can create calendar events via a scheduling API:
User: Remind me to call the dentist tomorrow at 2 PM.
Agent (tool call): create_reminder(text: "call dentist", time: "tomorrow 2 PM")
Tool result: "Reminder created for tomorrow at 2:00 PM."
Agent (final response): Done! I've set a reminder for tomorrow at 2 PM.
A sandbox tool that runs code snippets safely (e.g., using Docker or a cloud function):
User: What's the output of this Python code? [code]
Agent (tool call): run_python(code: "...")
Tool result: "42"
Agent (final response): The code outputs 42.
Each of these follows the same pattern: define the tool, let the LLM decide when to call it, execute, and feed the result back.
tools.ts.executeTool switch statement.That's it. Since the LLM uses the schema to decide when to call the tool, you don't need to write any conditional logic for when to use it—just how to execute it.
The current memory is ephemeral. To make it persistent:
You could also extract and store user preferences (e.g., "always respond in Spanish") as system messages injected into the context.
The current loop is a simple while loop. To add planning:
The OpenAI SDK is hardcoded, but swapping providers is straightforward:
For streaming, use the SDK's streaming mode and emit tokens as they arrive:
const stream = await client.chat.completions.create({ ..., stream: true });
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content || "");
}
For concurrency, run multiple agent instances in parallel. Each instance has its own messages array, so there's no shared state to worry about.
There's a growing movement of developers building minimal versions of complex systems to understand them. Think of Andrej Karpathy's nanoGPT for language models, or the many "build your own X" tutorials for databases, frameworks, and compilers.
pi-from-scratch is part of this trend. It treats AI agents not as magic, but as engineering problems with clear solutions.
When you use LangChain, you learn LangChain. When you build from scratch, you learn:
Be honest about what this project is not:
eval, which is a code injection vulnerability.These aren't flaws—they're teaching opportunities. Each limitation is a lesson in what production agents need.
The goal is to reach a point where you could rebuild your agent from memory.
As of early 2025, pi-from-scratch has generated discussion on Hacker News and Reddit. Developers are drawn to the project's simplicity and its contrast with the complexity of established frameworks.
Common sentiments include:
The community has started forking the repo to add:
The project's simplicity makes it an ideal foundation for experimentation.
A GitHub repository (SaladDay/pi-from-scratch) that implements a minimal AI agent in about 600 lines of TypeScript, focusing on clarity and educational value rather than production readiness.
Basic familiarity with JavaScript or TypeScript is helpful, but the core concepts (function calling, loops, API requests) translate to any language. You could port it to Python, Go, or Rust with relative ease.
The default implementation uses OpenAI's API (e.g., gpt-4o-mini for cost efficiency), but the pattern works with any LLM provider that supports function calling.
No. The project is explicitly educational. It lacks security hardening, persistence, rate limiting, and error recovery. Use it to learn, not to deploy.
The agent sends tool definitions (JSON schemas) to the LLM as part of the prompt. When the model decides a tool is needed, it outputs a structured tool-call request. Your code executes the function and feeds the result back to the model.
Yes, but it's minimal: an in-memory array of conversation messages. It doesn't persist across sessions and isn't summarized or compressed.
If you're comfortable with TypeScript and basic async/await patterns, you can read the entire core file in 30–60 minutes. Understanding it deeply might take an afternoon of experimentation.
Just the LLM provider SDK (e.g., openai), dotenv for environment variables, and TypeScript tooling. No agent frameworks.
Absolutely. That's the point. Add tools, change the model, implement persistence, or build a web interface. The simple architecture makes extensions straightforward.
Search GitHub for SaladDay/pi-from-scratch. The README includes setup instructions and examples.
Ready to demystify AI agents? Clone the pi-from-scratch repository, follow the guide, and start building your own agent today. Share your experience and extensions with the community.
Start small: add one tool. Then another. Then break something and fix it. That's how you learn.
The black box is open. Now go look inside.