Building a Safe, Local AI Coding Agent with Node.js A developer built a safe, local AI coding agent using Node.js and the Mistral LLM on Ollama. The agent runs entirely on the user's machine without any paid subscriptions, inspecting files and returning patch proposals for human review. The project demonstrates core agent patterns with plain JavaScript, including tool dispatch and safety constraints. Welcome to the 4th article of the MCP and RAG with JS series. In this article, we will learn what AI agents are by building a practical, beginner-friendly coding agent in JavaScript. We will use a locally running LLM, Mistral on Ollama. You do not need any paid subscription or API key. Everything runs locally on your machine, so this is accessible for learning, testing, and experimenting. We are building a local personal coding agent. It runs in the terminal and helps us understand a JavaScript project. It can: The important safety rule is this: The agent can inspect files, but it does not directly edit them. If it wants to change something, it only returns a patch proposal for a human developer to review. So in simple words, we are building a small local coding assistant. AI agents can sound complicated, but most agent systems use a few common patterns. In this project, we will learn those patterns with plain JavaScript: These same ideas appear in larger AI-agent frameworks. This project keeps them small enough to understand. A normal chatbot usually works like this: php User asks question - Model answers An agent works more like this: php User asks question - Model decides what to do - JavaScript runs a safe tool - Model sees the tool result - Model answers The model does not directly read your files or run commands. It asks for a tool, and your JavaScript code decides whether that tool is allowed. That is the main idea behind this project. You can explore and clone the complete codebase in the coding-agents GitHub repository. https://github.com/gaurav101/ai-experiment/tree/main/coding-agents The important files are: . |-- package.json |-- src | |-- cli.js | |-- agent.js | |-- ollama.js | -- tools.js -- test -- tools.test.js Each file has a clear job: src/cli.js : terminal entry point src/agent.js : agent loop and tool dispatch src/ollama.js : local Ollama API client src/tools.js : safe filesystem tools test/tools.test.js : safety and tool behavior testsThe app starts in src/cli.js . It imports the agent: js import { runAgent } from "./agent.js"; Then it chooses the project root and model: js const root = options.root || process.cwd ; const model = options.model || process.env.OLLAMA MODEL || "mistral"; This means: --root if the user provides it --model if provided OLLAMA MODEL mistral The CLI supports two modes. One-shot mode: npm start -- "Explain src/tools.js" Interactive mode: npm start In both cases, the CLI eventually calls: js const answer = await runAgent { goal, root, model, verbose } ; So the CLI is only responsible for input and output. The real agent behavior lives in runAgent . The main function is in src/agent.js : export async function runAgent { goal, root, model, verbose = false } { At the start, it creates the available tools: js const { createTools } = await import "./tools.js" ; const tools = createTools { root } ; Passing root is important. It tells the tools which folder they are allowed to inspect. Then the agent creates a message history: js const messages = { role: "system", content: buildSystemPrompt tools }, { role: "user", content: goal } ; The system message contains rules for the model. The user message contains the developer's request. Then the agent runs a loop: js for let step = 1; step <= MAX STEPS; step += 1 { const prompt = renderPrompt messages ; const raw = await generateWithOllama { prompt, model } ; const action = parseAction raw ; // final answer or tool call } MAX STEPS is set to 8 , so the agent cannot loop forever. This loop is the heart of the agent: php messages - prompt - model - action - tool or final answer The system prompt tells the model how to behave. In this project, the prompt says the model should: propose patch only for suggested changesIt also describes the available tools: js const toolDescriptions = Object.entries tools .map name, tool = - ${name}: ${tool.description} Parameters: ${JSON.stringify tool.parameters } .join "\n" ; This lets the model know what it can ask for. The model must respond in one of two shapes. To call a tool: {"type":"tool","name":"read file","arguments":{"path":"src/example.js"}} To finish: {"type":"final","answer":"Your answer here."} This is a simple JSON action protocol. It is easy to understand because there is no hidden framework magic. The file src/ollama.js keeps the Ollama API call separate from the rest of the app. The default local URL is: js const DEFAULT OLLAMA URL = "http://127.0.0.1:11434"; The function sends the prompt to Ollama: js const response = await fetch ${baseUrl}/api/generate , { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify { model, prompt, stream: false, options: { temperature } } } ; Then it returns the model text: js const data = await response.json ; return data.response || ""; This file has one job: php prompt in - Ollama HTTP request - model response out Keeping this in one file makes it easier to swap models later. After Ollama responds, the agent parses the response: js const action = parseAction raw ; If the model gives a final answer, the agent returns it: if action.type === "final" { return action.answer; } If the model asks for a tool, the agent checks whether that tool exists: js const tool = tools action.name ; if tool { messages.push { role: "tool", content: JSON.stringify { error: Unknown tool: ${action.name} , allowedTools: Object.keys tools } } ; continue; } This is very important. The model cannot invent tools. It can only use tools that exist in the local JavaScript object. Then the agent runs the tool: js const result = await tool.run action.arguments || {} ; And sends the result back into the message history: messages.push { role: "tool", content: JSON.stringify { tool: action.name, result } } ; Now the model can use real project information instead of guessing. Example flow: User: Explain src/tools.js Model: calls read file JavaScript: reads the file safely Model: sees the file content Model: gives final explanation The tools live in src/tools.js . The project has four tools: list files read file search text propose patch Each tool has: description : tells the model what it does parameters : tells the model what arguments it accepts run : the actual JavaScript functionExample shape: read file: { description: "Read a UTF-8 text file from inside the project root.", parameters: { path: "File path relative to project root." }, run: async { path: filePath } = {} = { // safe implementation } } The tools are intentionally narrow. list files lists files under the project root. read file reads one text file if it is safe and not too large. search text searches project files for a string or regex. propose patch returns a patch proposal, but does not apply it. That last point matters. The model can suggest changes, but a human still reviews them. use a safe regex engine if you are planning to expose the agent to external user inputs. safeResolve The most important safety function is: js export function safeResolve root, requestedPath { const absolute = path.resolve root, requestedPath ; const relative = path.relative root, absolute ; if relative.startsWith ".." || path.isAbsolute relative { throw new Error Path escapes project root: ${requestedPath} ; } return absolute; } This blocks path traversal. For example, this should be allowed: src/tools.js But this should be blocked: ../outside.txt Why? Because the agent should only inspect the selected project folder. Model output is not trusted input, so every requested path goes through safeResolve . This is one of the most important lessons in agent development: Give the model useful tools, but put real safety checks in code. The tool layer also avoids reading huge files: js const MAX READ BYTES = 80 000; const MAX SEARCH FILE BYTES = 250 000; read file throws an error if a file is too large. search text skips files that are too large. This protects the model context and keeps the agent responsive. The propose patch tool returns: { summary, patch, applied: false, note: "Patch proposal only. Review before applying." } This is a human-in-the-loop design. The agent can help you think and propose changes, but it does not silently modify your files. For a beginner agent, this is a good safety tradeoff. The project uses Vitest. From package.json : { "scripts": { "test": "vitest run", "test:watch": "vitest" } } The tests cover the dangerous parts: safeResolve allows normal paths safeResolve blocks ../ traversal list files handles missing directories read file rejects missing paths and directories read file rejects large files search text skips large files propose patch returns applied: false Example traversal test: js expect = safeResolve fixtureProjectRoot, "../outside.txt" .toThrow /escapes project root/ ; Example large-file test: await expect tools.read file.run { path: "large-read.txt" } .rejects.toThrow /too large to read safely/ ; Tests are not just for correctness here. They protect the safety boundary of the agent. If you run: npm start -- "Explain src/tools.js" the flow is: src/cli.js receives the request. runAgent . src/agent.js creates safe tools. src/ollama.js sends the prompt to local Ollama.That is an AI agent in practical terms. It is an LLM connected to a controlled loop, safe tools, and clear rules. Requirements: Pull the model: ollama pull mistral Start Ollama: ollama serve Run the agent: npm start Ask one question: npm start -- "Explain src/agent.js" Inspect another project: npm start -- --root /path/to/project "Find bugs in the main CLI file" Run tests: npm test Run syntax checks: npm run check An AI agent is not just an LLM. An AI agent is usually: LLM + loop + tools + context + safety rules In this project: safeResolve protects file accessThe model can request actions, but JavaScript decides what actually runs. That is the key idea. This project is intentionally small, but it teaches the foundation behind many larger agent systems. Once you understand this version, you can explore more advanced ideas like: But the core idea stays the same: Mistral is a good simple default for learning, but when you are building stronger coding agents, coding-focused models usually give better results. Good options to try are qwen2.5-coder, deepseek-coder, and Gemma. Build useful tools, keep them narrow, and let your application code enforce the safety boundaries.