{"slug": "building-a-safe-local-ai-coding-agent-with-node-js", "title": "Building a Safe, Local AI Coding Agent with Node.js", "summary": "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.", "body_md": "Welcome to the 4th article of the MCP and RAG with JS series.\n\nIn 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.\n\nYou do not need any paid subscription or API key. Everything runs locally on your machine, so this is accessible for learning, testing, and experimenting.\n\nWe are building a local personal coding agent.\n\nIt runs in the terminal and helps us understand a JavaScript project. It can:\n\nThe important safety rule is this:\n\nThe 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.\n\nSo in simple words, we are building a small local coding assistant.\n\nAI agents can sound complicated, but most agent systems use a few common patterns.\n\nIn this project, we will learn those patterns with plain JavaScript:\n\nThese same ideas appear in larger AI-agent frameworks. This project keeps them small enough to understand.\n\nA normal chatbot usually works like this:\n\n``` php\nUser asks question -> Model answers\n```\n\nAn agent works more like this:\n\n``` php\nUser asks question\n  -> Model decides what to do\n  -> JavaScript runs a safe tool\n  -> Model sees the tool result\n  -> Model answers\n```\n\nThe 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.\n\nThat is the main idea behind this project.\n\nYou can explore and clone the complete codebase in the [coding-agents GitHub repository.](https://github.com/gaurav101/ai-experiment/tree/main/coding-agents)\n\nThe important files are:\n\n```\n.\n|-- package.json\n|-- src\n|   |-- cli.js\n|   |-- agent.js\n|   |-- ollama.js\n|   `-- tools.js\n`-- test\n    `-- tools.test.js\n```\n\nEach file has a clear job:\n\n`src/cli.js`\n\n: terminal entry point`src/agent.js`\n\n: agent loop and tool dispatch`src/ollama.js`\n\n: local Ollama API client`src/tools.js`\n\n: safe filesystem tools`test/tools.test.js`\n\n: safety and tool behavior testsThe app starts in `src/cli.js`\n\n.\n\nIt imports the agent:\n\n``` js\nimport { runAgent } from \"./agent.js\";\n```\n\nThen it chooses the project root and model:\n\n``` js\nconst root = options.root || process.cwd();\nconst model = options.model || process.env.OLLAMA_MODEL || \"mistral\";\n```\n\nThis means:\n\n`--root`\n\nif the user provides it`--model`\n\nif provided`OLLAMA_MODEL`\n\n`mistral`\n\nThe CLI supports two modes.\n\nOne-shot mode:\n\n```\nnpm start -- \"Explain src/tools.js\"\n```\n\nInteractive mode:\n\n```\nnpm start\n```\n\nIn both cases, the CLI eventually calls:\n\n``` js\nconst answer = await runAgent({ goal, root, model, verbose });\n```\n\nSo the CLI is only responsible for input and output. The real agent behavior lives in `runAgent`\n\n.\n\nThe main function is in `src/agent.js`\n\n:\n\n```\nexport async function runAgent({ goal, root, model, verbose = false }) {\n```\n\nAt the start, it creates the available tools:\n\n``` js\nconst { createTools } = await import(\"./tools.js\");\nconst tools = createTools({ root });\n```\n\nPassing `root`\n\nis important. It tells the tools which folder they are allowed to inspect.\n\nThen the agent creates a message history:\n\n``` js\nconst messages = [\n  {\n    role: \"system\",\n    content: buildSystemPrompt(tools)\n  },\n  {\n    role: \"user\",\n    content: goal\n  }\n];\n```\n\nThe `system`\n\nmessage contains rules for the model. The `user`\n\nmessage contains the developer's request.\n\nThen the agent runs a loop:\n\n``` js\nfor (let step = 1; step <= MAX_STEPS; step += 1) {\n  const prompt = renderPrompt(messages);\n  const raw = await generateWithOllama({ prompt, model });\n  const action = parseAction(raw);\n\n  // final answer or tool call\n}\n```\n\n`MAX_STEPS`\n\nis set to `8`\n\n, so the agent cannot loop forever.\n\nThis loop is the heart of the agent:\n\n``` php\nmessages -> prompt -> model -> action -> tool or final answer\n```\n\nThe system prompt tells the model how to behave.\n\nIn this project, the prompt says the model should:\n\n`propose_patch`\n\nonly for suggested changesIt also describes the available tools:\n\n``` js\nconst toolDescriptions = Object.entries(tools)\n  .map(([name, tool]) => `- ${name}: ${tool.description} Parameters: ${JSON.stringify(tool.parameters)}`)\n  .join(\"\\n\");\n```\n\nThis lets the model know what it can ask for.\n\nThe model must respond in one of two shapes.\n\nTo call a tool:\n\n```\n{\"type\":\"tool\",\"name\":\"read_file\",\"arguments\":{\"path\":\"src/example.js\"}}\n```\n\nTo finish:\n\n```\n{\"type\":\"final\",\"answer\":\"Your answer here.\"}\n```\n\nThis is a simple JSON action protocol. It is easy to understand because there is no hidden framework magic.\n\nThe file `src/ollama.js`\n\nkeeps the Ollama API call separate from the rest of the app.\n\nThe default local URL is:\n\n``` js\nconst DEFAULT_OLLAMA_URL = \"http://127.0.0.1:11434\";\n```\n\nThe function sends the prompt to Ollama:\n\n``` js\nconst response = await fetch(`${baseUrl}/api/generate`, {\n  method: \"POST\",\n  headers: {\n    \"content-type\": \"application/json\"\n  },\n  body: JSON.stringify({\n    model,\n    prompt,\n    stream: false,\n    options: {\n      temperature\n    }\n  })\n});\n```\n\nThen it returns the model text:\n\n``` js\nconst data = await response.json();\nreturn data.response || \"\";\n```\n\nThis file has one job:\n\n``` php\nprompt in -> Ollama HTTP request -> model response out\n```\n\nKeeping this in one file makes it easier to swap models later.\n\nAfter Ollama responds, the agent parses the response:\n\n``` js\nconst action = parseAction(raw);\n```\n\nIf the model gives a final answer, the agent returns it:\n\n```\nif (action.type === \"final\") {\n  return action.answer;\n}\n```\n\nIf the model asks for a tool, the agent checks whether that tool exists:\n\n``` js\nconst tool = tools[action.name];\nif (!tool) {\n  messages.push({\n    role: \"tool\",\n    content: JSON.stringify({\n      error: `Unknown tool: ${action.name}`,\n      allowedTools: Object.keys(tools)\n    })\n  });\n  continue;\n}\n```\n\nThis is very important.\n\nThe model cannot invent tools. It can only use tools that exist in the local JavaScript object.\n\nThen the agent runs the tool:\n\n``` js\nconst result = await tool.run(action.arguments || {});\n```\n\nAnd sends the result back into the message history:\n\n```\nmessages.push({\n  role: \"tool\",\n  content: JSON.stringify({\n    tool: action.name,\n    result\n  })\n});\n```\n\nNow the model can use real project information instead of guessing.\n\nExample flow:\n\n```\nUser: Explain src/tools.js\nModel: calls read_file\nJavaScript: reads the file safely\nModel: sees the file content\nModel: gives final explanation\n```\n\nThe tools live in `src/tools.js`\n\n.\n\nThe project has four tools:\n\n```\nlist_files\nread_file\nsearch_text\npropose_patch\n```\n\nEach tool has:\n\n`description`\n\n: tells the model what it does`parameters`\n\n: tells the model what arguments it accepts`run`\n\n: the actual JavaScript functionExample shape:\n\n```\nread_file: {\n  description: \"Read a UTF-8 text file from inside the project root.\",\n  parameters: {\n    path: \"File path relative to project root.\"\n  },\n  run: async ({ path: filePath } = {}) => {\n    // safe implementation\n  }\n}\n```\n\nThe tools are intentionally narrow.\n\n`list_files`\n\nlists files under the project root.\n\n`read_file`\n\nreads one text file if it is safe and not too large.\n\n`search_text`\n\nsearches project files for a string or regex.\n\n`propose_patch`\n\nreturns a patch proposal, but does not apply it.\n\nThat last point matters. The model can suggest changes, but a human still reviews them.\n\n** use a safe regex engine if you are planning to expose the agent to external user inputs. **\n\n`safeResolve`\n\nThe most important safety function is:\n\n``` js\nexport function safeResolve(root, requestedPath) {\n  const absolute = path.resolve(root, requestedPath);\n  const relative = path.relative(root, absolute);\n\n  if (relative.startsWith(\"..\") || path.isAbsolute(relative)) {\n    throw new Error(`Path escapes project root: ${requestedPath}`);\n  }\n\n  return absolute;\n}\n```\n\nThis blocks path traversal.\n\nFor example, this should be allowed:\n\n```\nsrc/tools.js\n```\n\nBut this should be blocked:\n\n```\n../outside.txt\n```\n\nWhy?\n\nBecause the agent should only inspect the selected project folder. Model output is not trusted input, so every requested path goes through `safeResolve`\n\n.\n\nThis is one of the most important lessons in agent development:\n\nGive the model useful tools, but put real safety checks in code.\n\nThe tool layer also avoids reading huge files:\n\n``` js\nconst MAX_READ_BYTES = 80_000;\nconst MAX_SEARCH_FILE_BYTES = 250_000;\n```\n\n`read_file`\n\nthrows an error if a file is too large.\n\n`search_text`\n\nskips files that are too large.\n\nThis protects the model context and keeps the agent responsive.\n\nThe `propose_patch`\n\ntool returns:\n\n```\n{\n  summary,\n  patch,\n  applied: false,\n  note: \"Patch proposal only. Review before applying.\"\n}\n```\n\nThis is a human-in-the-loop design.\n\nThe agent can help you think and propose changes, but it does not silently modify your files.\n\nFor a beginner agent, this is a good safety tradeoff.\n\nThe project uses Vitest.\n\nFrom `package.json`\n\n:\n\n```\n{\n  \"scripts\": {\n    \"test\": \"vitest run\",\n    \"test:watch\": \"vitest\"\n  }\n}\n```\n\nThe tests cover the dangerous parts:\n\n`safeResolve`\n\nallows normal paths`safeResolve`\n\nblocks `../`\n\ntraversal`list_files`\n\nhandles missing directories`read_file`\n\nrejects missing paths and directories`read_file`\n\nrejects large files`search_text`\n\nskips large files`propose_patch`\n\nreturns `applied: false`\n\nExample traversal test:\n\n``` js\nexpect(() => safeResolve(fixtureProjectRoot, \"../outside.txt\")).toThrow(/escapes project root/);\n```\n\nExample large-file test:\n\n```\nawait expect(tools.read_file.run({ path: \"large-read.txt\" })).rejects.toThrow(/too large to read safely/);\n```\n\nTests are not just for correctness here. They protect the safety boundary of the agent.\n\nIf you run:\n\n```\nnpm start -- \"Explain src/tools.js\"\n```\n\nthe flow is:\n\n`src/cli.js`\n\nreceives the request.`runAgent`\n\n.`src/agent.js`\n\ncreates safe tools.`src/ollama.js`\n\nsends the prompt to local Ollama.That is an AI agent in practical terms.\n\nIt is an LLM connected to a controlled loop, safe tools, and clear rules.\n\nRequirements:\n\nPull the model:\n\n```\nollama pull mistral\n```\n\nStart Ollama:\n\n```\nollama serve\n```\n\nRun the agent:\n\n```\nnpm start\n```\n\nAsk one question:\n\n```\nnpm start -- \"Explain src/agent.js\"\n```\n\nInspect another project:\n\n```\nnpm start -- --root /path/to/project \"Find bugs in the main CLI file\"\n```\n\nRun tests:\n\n```\nnpm test\n```\n\nRun syntax checks:\n\n```\nnpm run check\n```\n\nAn AI agent is not just an LLM.\n\nAn AI agent is usually:\n\n```\nLLM + loop + tools + context + safety rules\n```\n\nIn this project:\n\n`safeResolve`\n\nprotects file accessThe model can request actions, but JavaScript decides what actually runs.\n\nThat is the key idea.\n\nThis project is intentionally small, but it teaches the foundation behind many larger agent systems.\n\nOnce you understand this version, you can explore more advanced ideas like:\n\nBut the core idea stays the same:\n\nMistral 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.\n\nBuild useful tools, keep them narrow, and let your application code enforce the safety boundaries.", "url": "https://wpnews.pro/news/building-a-safe-local-ai-coding-agent-with-node-js", "canonical_source": "https://dev.to/gaurav101/building-a-safe-local-ai-coding-agent-with-nodejs-46ol", "published_at": "2026-06-19 03:57:12+00:00", "updated_at": "2026-06-19 04:30:08.256789+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models", "ai-safety"], "entities": ["Node.js", "Mistral", "Ollama", "JavaScript", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/building-a-safe-local-ai-coding-agent-with-node-js", "markdown": "https://wpnews.pro/news/building-a-safe-local-ai-coding-agent-with-node-js.md", "text": "https://wpnews.pro/news/building-a-safe-local-ai-coding-agent-with-node-js.txt", "jsonld": "https://wpnews.pro/news/building-a-safe-local-ai-coding-agent-with-node-js.jsonld"}}