{"slug": "build-a-basic-ai-agent-from-scratch-tools", "title": "Build A Basic AI Agent From Scratch: Tools", "summary": "A developer published a tutorial on building a basic AI agent from scratch, adding tool-calling capabilities such as bash execution, file reading, and glob file searching. The tutorial builds on a previous basic agent harness and uses modern LLMs' native tool calling for reliability. These tools allow the agent to take actions on the computer, with security concerns noted for future mitigation.", "body_md": "In the previous part of the * Build A Basic AI Agent From Scratch* series, we built the most basic AI agent harness possible.\nIt was just a connection to a model, a way to take user input, a store of context of the conversation and a loop that kept the agent running.\n\nOf course, this agent is not very useful. It can only interact by taking your input and answering you based on its internal knowledge.\nIf we want our agent to be more useful and do work in behalf of us, we have to give it a way to give it some way to take actions in its environment. In this case, the computer it's running on. The way you allow an agent to take actions in your computer is with **tools**.\n\nA **tool** is a *program or function* that you expose to your LLM to allow it to invoke it autonomously. A tool can be as simple as a Python function implemented in the same agent code and as complex as an MCP (Model Context Protocol) server that does a HTTP request to an API that reads or updates a database.\n\nNote: MCP is not covered in this part of the series but it will be covered in the future.\n\nLarge Language Models output text, so how can they use tools? The first implementations of tool calling relied on suggesting the LLM to\noutput a text like *Action: web_fetch* and then the agent harness parsing the text output and running the function. This was a bit unreliable, since the model sometimes didn't exactly follow the format we were expecting.\n\nModern LLMs already have native tool calling baked into them to make this more reliable. These models are fine-tuned to produce JSON structured tool requests. This native implementation has built-in validation, which minimizes hallucinations and makes the agent more reliable when it has to invoke a tool.\n\nWe will be building on our previous basic agent we already built in the last part of this series: [Build A Basic AI Agent From Scratch](https://www.ruxu.dev/articles/ai/build-a-basic-ai-agent/).\n\nWe will start by implementing the most basic tools an AI agent needs to take action. These tools are usually built-in the most common agent harnesses. All of them are simple, but essential and powerful.\n\nIn the previous Python code, we will create a *tools* submodule. Here we will implement all our tools and their schemas.\n\nFirst, let's start with the **bash** tool:\n\n``` php\ndef run_bash(command: str) -> str:\n    \"\"\"Run a bash command and return its output.\"\"\"\n    result = subprocess.run(\n        command, shell=True, text=True, capture_output=True\n    )\n    output = result.stdout\n    if result.stderr:\n        output += f\"\\nSTDERR:\\n{result.stderr}\"\n    return output or \"(no output)\"\n```\n\nThis is the most powerful tool. Allowing our agent to run bash commands will let it do anything on the computer it's running on. On one hand, this is good because it relieves us from implementing a tool for each program that can just be run using bash and that the LLM already knows how to use. On the other hand, this is the most dangerous tool (also because it will let it do anything on the computer it's running on). In future parts of this series we will crack down on security so this doesn't become a liability.\n\nThe next tool is the **read file** tool:\n\n``` php\ndef read_file(path: str, offset: int = 1, limit: int = 200) -> str:\n    \"\"\"Read lines from a file, with optional offset and limit.\"\"\"\n    p = Path(path)\n    if not p.exists():\n        return f\"Error: file not found: {path}\"\n    lines = p.read_text(errors=\"replace\").splitlines()\n    selected = lines[offset - 1: offset - 1 + limit]\n    return \"\\n\".join(f\"{offset + i}: {line}\" for i, line in enumerate(selected))\n```\n\nThis allows our agent to read the files on the computer. This is useful for many cases, like for example reading all the files in our codebase for coding agents.\n\nThe next tool is the **glob files** tool:\n\n``` php\ndef glob_files(pattern: str, path: str = \".\") -> str:\n    \"\"\"Find files matching a glob pattern inside a directory.\"\"\"\n    matches = glob_module.glob(f\"{path}/**/{pattern}\", recursive=True)\n    matches += glob_module.glob(f\"{path}/{pattern}\")\n    unique = sorted(set(matches))\n    return \"\\n\".join(unique) if unique else \"(no matches)\"\n```\n\nThis tool can be used to find files in a directory. Obviously needed so the agent can explore your computer and see which files are available before it reads them.\n\nThe next tool is the **grep** tool:\n\n``` php\ndef grep(pattern: str, path: str = \".\", include: str = \"*\") -> str:\n    \"\"\"Search file contents for a regex pattern, optionally filtering by filename glob.\"\"\"\n    results = []\n    for filepath in glob_module.glob(f\"{path}/**/{include}\", recursive=True):\n        fp = Path(filepath)\n        if not fp.is_file():\n            continue\n        try:\n            for i, line in enumerate(fp.read_text(errors=\"replace\").splitlines(), 1):\n                if re.search(pattern, line):\n                    results.append(f\"{filepath}:{i}: {line}\")\n        except OSError:\n            pass\n    return \"\\n\".join(results) if results else \"(no matches)\"\n```\n\nThis tool searches file contents using regular expressions and returns matching lines together with their file path and line number. It complements `glob_files`\n\nnicely: first you find which files exist, then you search inside them for the content you are actually interested in. The optional `include`\n\nparameter lets you restrict the search to files matching a filename pattern, which is useful to avoid searching binary files or to narrow the scope to a specific language.\n\nThe next tool is the **write file** tool:\n\n``` php\ndef write_file(path: str, content: str) -> str:\n    \"\"\"Write content to a file, creating it if it does not exist.\"\"\"\n    p = Path(path)\n    p.parent.mkdir(parents=True, exist_ok=True)\n    p.write_text(content)\n    return f\"Wrote {len(content)} bytes to {path}\"\n```\n\nThis tool lets our agent create new files and write content to them. It automatically creates any missing parent directories, so the agent doesn't have to worry about the directory structure already existing. This is essential for any agent that needs to produce output, generate code, or save results to disk.\n\nThe next tool is the **edit file** tool:\n\n``` php\ndef edit_file(path: str, old_string: str, new_string: str) -> str:\n    \"\"\"Replace the first occurrence of old_string with new_string in a file.\"\"\"\n    p = Path(path)\n    if not p.exists():\n        return f\"Error: file not found: {path}\"\n    original = p.read_text()\n    if old_string not in original:\n        return f\"Error: string not found in {path}\"\n    p.write_text(original.replace(old_string, new_string, 1))\n    return f\"Edited {path}\"\n```\n\nWhile `write_file`\n\nreplaces the entire content of a file, `edit_file`\n\nperforms a targeted string replacement. This is much safer when the agent only needs to make a small change to an existing file, since it avoids accidentally overwriting content it hasn't read. It is the go-to tool for coding agents that need to patch specific lines without rewriting everything.\n\nThe last tool is the **webfetch** tool:\n\n``` php\ndef webfetch(url: str) -> str:\n    \"\"\"Fetch a URL and return its full plain-text content (up to 2 MB).\"\"\"\n    parsed = urlparse(url)\n    if parsed.scheme not in (\"http\", \"https\"):\n        return f\"Error fetching {url}: unsupported scheme '{parsed.scheme}'.\"\n    req = urllib.request.Request(url, headers={\"User-Agent\": \"agent/1.0\"})\n    with urllib.request.urlopen(req, timeout=15) as resp:\n        raw = b\"\".join(...).decode(charset, errors=\"replace\")\n    soup = BeautifulSoup(raw, \"html.parser\")\n    text = soup.get_text(separator=\"\\n\", strip=True)\n    return re.sub(r\"\\n{3,}\", \"\\n\\n\", text).strip()\n```\n\nThis tool fetches a public web page and returns its content as plain text. It uses BeautifulSoup to strip all the HTML markup so the model only receives the readable text, keeping the context clean and token-efficient. It is restricted to `http`\n\nand `https`\n\nURLs and caps the response at 2 MB to avoid flooding the context window with enormous pages.\n\nOnce all our tools are implemented, we have to let the agent know they exist. The agent also needs to know what each tool does and which parameters it takes. We have to define a **tool schema** for the model:\n\n``` python\ndef get_tool_schemas():\n    return [\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": \"run_bash\",\n                \"description\": \"Run a bash command on the user's machine and return the output.\",\n                \"parameters\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"command\": {\n                            \"type\": \"string\",\n                            \"description\": \"The bash command to execute.\",\n                        }\n                    },\n                    \"required\": [\"command\"],\n                },\n            },\n        },\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": \"read_file\",\n                \"description\": \"Read lines from a file. Returns lines prefixed with line numbers.\",\n                \"parameters\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"path\": {\"type\": \"string\", \"description\": \"Absolute or relative path to the file.\"},\n                        \"offset\": {\"type\": \"integer\", \"description\": \"First line to read (1-indexed). Defaults to 1.\"},\n                        \"limit\": {\"type\": \"integer\", \"description\": \"Maximum number of lines to return. Defaults to 200.\"},\n                    },\n                    \"required\": [\"path\"],\n                },\n            },\n        },\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": \"glob_files\",\n                \"description\": \"Find files matching a glob pattern (e.g. '**/*.py') inside a directory.\",\n                \"parameters\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"pattern\": {\"type\": \"string\", \"description\": \"Glob pattern to match against file names.\"},\n                        \"path\": {\"type\": \"string\", \"description\": \"Root directory to search in. Defaults to '.'.\"},\n                    },\n                    \"required\": [\"pattern\"],\n                },\n            },\n        },\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": \"grep\",\n                \"description\": \"Search file contents for a regex pattern and return matching lines with file paths and line numbers.\",\n                \"parameters\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"pattern\": {\"type\": \"string\", \"description\": \"Regular expression to search for.\"},\n                        \"path\": {\"type\": \"string\", \"description\": \"Directory to search in. Defaults to '.'.\"},\n                        \"include\": {\"type\": \"string\", \"description\": \"Filename glob to restrict which files are searched (e.g. '*.py'). Defaults to '*'.\"},\n                    },\n                    \"required\": [\"pattern\"],\n                },\n            },\n        },\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": \"write_file\",\n                \"description\": \"Write content to a file, creating it (and any missing parent directories) if it does not exist.\",\n                \"parameters\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"path\": {\"type\": \"string\", \"description\": \"Path of the file to write.\"},\n                        \"content\": {\"type\": \"string\", \"description\": \"Full content to write to the file.\"},\n                    },\n                    \"required\": [\"path\", \"content\"],\n                },\n            },\n        },\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": \"edit_file\",\n                \"description\": \"Replace the first occurrence of a string in a file with a new string.\",\n                \"parameters\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"path\": {\"type\": \"string\", \"description\": \"Path of the file to edit.\"},\n                        \"old_string\": {\"type\": \"string\", \"description\": \"Exact string to find and replace.\"},\n                        \"new_string\": {\"type\": \"string\", \"description\": \"String to replace it with.\"},\n                    },\n                    \"required\": [\"path\", \"old_string\", \"new_string\"],\n                },\n            },\n        },\n        {\n            \"type\": \"function\",\n            \"function\": {\n                \"name\": \"webfetch\",\n                \"description\": (\n                    \"Fetch a public URL (http/https only) and return its full plain-text content (up to 2 MB).\"\n                ),\n                \"parameters\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"url\": {\"type\": \"string\", \"description\": \"The URL to fetch (http/https).\"},\n                    },\n                    \"required\": [\"url\"],\n                },\n            },\n        },\n    ]\n```\n\nThen, we can integrate the tools into our previous agent loop:\n\n```\nTOOL_REGISTRY = get_tool_registry()\nTOOL_SCHEMAS = get_tool_schemas()\n\ndef handle_tool_calls(tool_calls, messages):\n    \"\"\"Execute each tool the LLM requested and append the results to messages.\"\"\"\n    for tool_call in tool_calls:\n        name = tool_call.function.name\n        args = json.loads(tool_call.function.arguments)\n\n        print(f\"  [tool] {name}({args})\")\n\n        if name not in TOOL_REGISTRY:\n            result = f\"Error: unknown tool '{\n                name}'. Available tools: {list(TOOL_REGISTRY.keys())}\"\n        else:\n            result = TOOL_REGISTRY[name](**args)\n\n        print(f\"  [tool result] {result[:200]}{\n              '...' if len(result) > 200 else ''}\")\n\n        messages.append({\n            \"role\": \"tool\",\n            \"tool_call_id\": tool_call.id,\n            \"content\": result,\n        })\n\ndef agent_loop(client):\n    messages = [\n        {\n            \"role\": \"system\",\n            \"content\": (\n                \"You are a helpful assistant. You have tools to read and write files, \"\n                \"search the file system, and fetch web pages. Use them to help the user.\"\n            ),\n        }\n    ]\n\n    while True:\n        user_input = input(\"You: \")\n        if user_input.lower() == \"\\\\exit\":\n            break\n\n        messages.append({\"role\": \"user\", \"content\": user_input})\n\n        while True:\n            response = client.chat.completions.create(\n                model=\"gemma4\",\n                messages=messages,\n                tools=TOOL_SCHEMAS,\n                temperature=0.7,\n            )\n\n            message = response.choices[0].message\n\n            messages.append(message)\n\n            if message.tool_calls:\n                handle_tool_calls(message.tool_calls, messages)\n            else:\n                print(f\"Assistant: {message.content}\")\n                break\n```\n\nYou can find and clone this code in this blog series'\n\n[Github repo].\n\nLet's test our new and more powerful agent! If we run the updated agent we can use many tools to accomplish for example fetching a web page and writing a file based on it:\n\n``` bash\n$ python agent.py\nYou: Read the frontpage of ruxu.dev and list all the articles in a markdown file ruxu.md\n  [tool] webfetch({'url': 'https://ruxu.dev'})\n  [tool result] Blog | Roger Oriol\nRoger Oriol\nMy name is Roger Oriol, I am a Software Architect based in Barcelona, Spain. I am a MSc graduate in Big Data Management, Technologies and Analytics. This blog will be th...\n  [tool] write_file({'path': 'ruxu.md', 'content': '# Articles on ruxu.dev\\n\\n- Build a Basic AI Agent From Scratch\\n- 🔗 [Link] GPT-5\\n- 🔗 [Quote] GPT-5 variants\\n- 🔗 [Link] GPT-OSS\\n- 🔗 [Quote] How we built our multi-agent research system\\n- 🔗 [Link] Artificial Intelligence 3E: Foundations of computational agents\\n- 🔗 [Link] AGI is not multimodal\\n- 🔗 [Quote] Hype Coding - Steve Krouse\\n- 🔗 [Link] OpenAI Codex CLI\\n- 🔗 [Link] GPT 4.1'})\n  [tool result] Wrote 375 bytes to ruxu.md\nAssistant: Done — I created `ruxu.md` with the article list from the front page of ruxu.dev.\n```\n\nWe now have a tool-calling agent that is already very powerful. If you ask the agent to do something in your behalf, it can leverage all those basic tools to accomplish very complex tasks. Actually, this can already be used as a coding agent or assistant and it actually works. It's still lacking many features that Claude Code or Hermes Agent have, but we are slowly getting there.\n\nIf we use the current agent for a bit, we can get a glimpse of its potential, but we will often find that it uses tools without planning long-term and it often runs short on complex tasks. In the next part of this series, we will leverage tools by arming our agent with planning and task management tools that will allow it to be able to tackle longer running tasks.", "url": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-tools", "canonical_source": "https://ruxu.dev/articles/ai/build-an-ai-agent-with-tools/", "published_at": "2026-05-31 00:00:00+00:00", "updated_at": "2026-06-17 14:24:38.796529+00:00", "lang": "en", "topics": ["ai-agents", "large-language-models", "developer-tools", "ai-tools"], "entities": ["ruxu.dev", "MCP", "Model Context Protocol"], "alternates": {"html": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-tools", "markdown": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-tools.md", "text": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-tools.txt", "jsonld": "https://wpnews.pro/news/build-a-basic-ai-agent-from-scratch-tools.jsonld"}}