I rewrote the system prompt fourteen times.
Version 3 was "be thorough." Version 7 was a 900-word manifesto about edge cases. Version 12 threatened the model if it hallucinated a file path. Version 14 still booked a meeting on the wrong calendar and cheerfully summarized the disaster.
The agent wasn't dumb. It was unarmed.
If you've been stuck in prompt-tweaking hell with an AI agent that keeps almost-working, this is the post I wish I'd read first. The fix usually isn't another paragraph of instructions. It's giving the thing a real tool.
Prompts are pep talks. Tools are hands.
A prompt is a pep talk before a job interview. A tool is the résumé, the laptop, and the door badge.
You can coach someone forever on "be professional and double-check details." If they can't open the calendar API, they will invent a meeting time that sounds right. LLMs are the same — world-class at sounding competent, terrible at touching the real world unless you wire them up.
That's the agentic AI shift in one sentence: stop asking the model to pretend it did something. Give it a function it can actually call.
The night the prompt lost and the tool won
My agent had one job: when a GitHub issue is labeled needs-repro, clone the repo, run the failing test, and paste the output back.
I tried better prompts first — "always clone before you speculate," "never invent stack traces," "if you're unsure, say so." It invented a beautifully formatted stack trace from a file that didn't exist. From the model's point of view, a plausible stack trace is the assignment when the only tool it has is "generate text."
Then I registered three boring tools: clone_repo(url), run_tests(path), comment_on_issue(number, body). No poetry. Next run: clone → run → paste real output. The intelligence didn't jump. The surface area of reality did.
A tiny tool-registration pattern you can steal
import json, subprocess
from dataclasses import dataclass
from typing import Callable
@dataclass
class Tool:
name: str
description: str
schema: dict
handler: Callable[[dict], str]
def run_tests(args: dict) -> str:
path = args["path"]
# Never let the model shell out raw — pin the command.
result = subprocess.run(
["pytest", path, "-q"],
capture_output=True, text=True, timeout=120
)
return (result.stdout or result.stderr)[-4000:]
TOOLS = {
"run_tests": Tool(
name="run_tests",
description="Run pytest on a path; return the tail of output.",
schema={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
handler=run_tests,
),
}
def tools_for_llm() -> list[dict]:
"""Send names/descriptions/schemas — not handlers."""
return [{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.schema,
},
} for t in TOOLS.values()]
def dispatch(tool_name: str, arguments_json: str) -> str:
tool = TOOLS[tool_name]
return tool.handler(json.loads(arguments_json))
# Loop: messages + tools_for_llm()
# tool_call -> dispatch() -> append result -> repeat
# stop on normal message or max-steps guard
Notice what the prompt no longer has to say: "please don't invent pytest output." The model can't invent what run_tests already returned. Reality is a function return value, not a writing style.
Four rules that beat another prompt rewrite
1. If the model must touch the world, it needs a tool.
Email, APIs, files, tests, databases — tools, not adjectives in a system prompt.
2. Prefer boring, narrow tools over one god-tool.
run_tests(path) beats do_whatever(shell_command). Narrow tools are easier to log and sandbox. God-tools are how you wake up to a deleted directory and a polite apology.
3. Put constraints in code, not in prose.
Timeouts, allow-lists, max bytes, path sandboxes belong in the handler. The model is a creative writer under deadline. Your handler is the adult in the room.
4. Log tool calls like production traffic.
Name, args, duration, success/fail. When an agent goes weird, you won't debug prompt vibes — you'll debug the trace: it called clone_repo with a typo URL three times.
The pizza-shop test
Imagine a pizza shop where the only employee is a poet. Hand them a long script about greeting customers and using the oven. Without an oven dial, a ticket printer, and a delivery map, you don't have a pizza shop — you have spoken-word night with cheese anxiety.
Agents are the same. Prompting is coaching the poet. Tool-use is installing the oven. Multi-agent setups multiply the moral: researchers need search, coders need file/test tools, reviewers need diffs. More agents with no tools is just a group chat with confidence issues.
When you should still touch the prompt
Prompts are great at role ("prefer failing closed"), output shape ("short summary after tools"), and tie-breaking ("prefer the read-only tool"). They're bad at physics. "Don't hallucinate test output" is physics. Solve physics with a tool.
A 20-minute challenge
Pick one task your current bot fakes:
- Name the real-world action (
fetch_invoice,list_open_prs,tail_logs). - Write a ~15-line handler with a timeout and allow-list.
- Register it with a tight JSON schema.
- Delete two paragraphs of prompt compensating for the missing tool.
- Run it three times and read the tool trace, not the final essay.
If success jumps, you didn't find a better spell. You handed the wizard a screwdriver.
The punchline
Agentic AI feels magical in demos because demos hide the plumbing. In production, the magic is mostly tool surface area + boring guardrails + a short honest prompt.
Stop asking your agent to role-play competence. Give it hands. Keep the prompt short enough that you can still read it without scrolling past your own despair.
Your turn: What's one tool you wish your agent had yesterday — the specific function, not a vibe? Drop the name and two args in the comments. I'm collecting a hall-of-fame of "why didn't I add this sooner" tools.
Top comments (1)
Hello Glad to see you, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.
The distinction between prompt quality and tool surface area is extremely important for production agents. I would push this architecture one step further by treating every tool as a capability boundary rather than simply a function the model can invoke.
For example, run_tests(path) should ideally sit behind a policy layer that validates repository identity, filesystem scope, execution time, resource consumption, and permitted commands before dispatch. The LLM should never become the security boundary.
I also recommend introducing typed tool contracts with explicit preconditions and postconditions. A tool response should expose structured state such as success, error class, execution metadata, and artifacts rather than returning only a text blob. This makes the agent loop much easier to evaluate and recover.
The biggest improvement comes from observability. Capture the complete trajectory: model decision, tool selection, arguments, validation result, latency, output, retry count, and final state. Then evaluate tool selection separately from answer quality. Otherwise you cannot distinguish reasoning failure from capability failure.
For more complex agents, I would add capability scoped permissions and a state machine around irreversible actions. Read operations can remain autonomous while writes require policy validation or explicit approval.
Your pizza analogy is excellent. I would summarize the engineering principle as: prompts define intent, tools provide capabilities, policies define authority, and telemetry proves behavior.
This is exactly the direction I enjoy working on. Would be great to exchange ideas on reliable agent architecture and production tool orchestration.