CLI Wrappers vs. In-Process Functions
Ask an agent to "search our venue notes for Edinburgh pubs that have vegan options and can fit 160 people," and one of the simplest ways to build that tool is: let the model emit a search term, and have your Python code run it through grep on the command line — the same text-based interface you'd use typing commands into a terminal, wrapped so the model can call it. That's a CLI (command-line interface) tool: lowest setup cost of any of this lesson's two approaches, and, done carelessly, the highest security risk. Both are true at once, and this lesson is about the specific disciplines that make the risky one safe — plus the second approach, in-process functions, that everything else in this course builds on.
Prerequisite: Lesson 07 (what a tool is, and the five design principles — identity, schema, execution environment, side effects, trust boundary). This lesson assumes you already accept that a tool is a five-part contract, not a docstring.
After this lesson, you can: wrap a shell command as a safe agent tool without shell=True, and build a multi-tool dispatcher loop that survives a wrong tool name or a missing venue without crashing.
The question this lesson answers
Here's a claim worth testing before you write a single wrapper: CLI tools are dangerous, in-process functions are safe, so start with functions and treat CLI as a legacy fallback. That claim is wrong on both halves. CLI is the paradigm you reach for specifically because it's dangerous and cheap — every Unix utility becomes available with a few lines of code, no new infrastructure. And functions aren't safe by default either; they're foundational because MCP wraps this exact loop in a protocol, A2A wraps it in an agent, but the core is always the same: JSON in, Python executes, JSON out. Safety, in both cases, comes from specific disciplines you apply — not from which paradigm you picked.
Paradigm 1: CLI, the oldest and the most exposed
The pattern is direct: the model emits JSON, your code builds a command list, subprocess.run(...) executes it, and stdout or stderr comes back. Lowest setup cost of any paradigm. Highest security risk of any paradigm. Both are true at once, which is exactly why the four safety rules below are non-negotiable rather than optional hardening.
Pass argument lists, never shell strings. subprocess.run(f"grep {user_input} /data/*.txt", shell=True) lets a crafted user_input escape into shell interpretation — this is not theoretical, it's the first thing a red team tries. subprocess.run(["grep", "-i", "--", user_input, "/data/venues.txt"], capture_output=True, timeout=10) cannot be escaped this way: user_input is one argument, never shell syntax.
Permit a fixed set — {"grep", "wc", "git status"} — rather than accepting arbitrary input as the command itself. The model chooses parameters within a command you already decided to trust; it never chooses the command.
Resolve every path to absolute, and confirm it falls within the allowed root before touching the filesystem. The model should only ever influence the search term, never the location being searched.
Cap both wall-clock time and returned bytes. A hung subprocess or a multi-megabyte stdout dump is a denial-of-service vector against your own agent loop, not a hypothetical.
The Edinburgh CLI tool, built to the four rules
The capability: read Rod's local venue shortlist from a previous research session, and search it with grep for pubs that match capacity and dietary constraints — no API involved.
MAX_OUTPUT = 8_192 # 8 KB cap on stdout returned to the model
def search_venue_notes(query: str) -> dict:
"""Search local venue notes for a keyword using grep."""
venue_file = WORKSPACE / "venues.txt"
if not venue_file.exists():
return {"success": False, "error": "venues.txt not found in workspace"}
try:
result = subprocess.run(
["grep", "-i", "--", query, str(venue_file)],
capture_output=True, text=True, timeout=5
)
lines = [l.strip() for l in result.stdout[:MAX_OUTPUT].splitlines() if l.strip()]
return {"success": True, "matches": lines,
"count": len(lines), "query": query}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Search timed out after 5 seconds"}
except Exception as e:
return {"success": False, "error": str(e)}Dispatched through openai-python against base_url="https://api.tokenfactory.nebius.com/v1/" with model="meta-llama/Llama-3.3-70B-Instruct", the loop is five lines of turn-taking: send the tools, read tool_calls, run search_venue_notes(**args), append the result, repeat until the model returns content instead of a tool call.
python search_lab.py
Three things fall out of running this against "Search our venue notes for Edinburgh pubs that have vegan options and can fit 160 people":
The model never touched the filesystem — it emitted JSON, and Python ran grep. shell=True was never used, so the query goes directly as a grep argument with no shell interpolation possible. And path traversal is impossible by construction: the file path is hardcoded, so the model can only influence the search query, nothing else. The wrapper is the security perimeter, not a suggestion layered on top of one.
Paradigm 2: in-process functions, the foundational pattern
Everything else in this course builds on this loop: model emits tool_call(name, {args}), your Python function runs, the result goes back to the model. MCP wraps this in a protocol (lesson 09). A2A wraps it in an agent (lesson 10). But strip either paradigm down and you find this same three-step handoff underneath.
The shift that made this reliable happened in June 2023. Before: "Action: check_pub(name='The Bow Bar')" — a string your code had to parse, fragile by construction. After: {"name": "check_pub_availability", "input": {"pub_name": "The Bow Bar"}} — schema-validated, no parsing required. Lesson 07 covers this history in full; here it's a one-line callback, because what matters for this lesson is what changed after that shift, not the shift itself.
| Feature | 2023 | 2026 |
|---|---|---|
| Parallel calls | One tool per turn | Multiple tools simultaneously |
| Tool choice | Model decides | tool_choice="required" forces it |
| Schema validation | Soft, best-effort JSON | strict: true, guaranteed valid |
| Thinking plus tools | Separate | Interleaved, in R1 and Kimi K2 |
The 2026 default is strict: true plus additionalProperties: false for every production tool — this is the schema-design principle from lesson 07 made concrete, and it eliminates what practitioners call the "2 AM parser error": a malformed tool call that used to crash a loop now simply can't be emitted.
The multi-tool Edinburgh agent
Three tools, one dispatcher, no framework: check_pub_availability(pub_name, required_capacity, requires_vegan) validates a venue, calculate_catering_cost(guests, price_per_head) estimates cost, and get_booking_deadline(event_date) computes hours until the 5 PM cutoff. Run against model="Qwen/Qwen3-235B-A22B-Instruct-2507".
TOOL_MAP = {
"check_pub_availability": check_pub_availability,
"calculate_catering_cost": calculate_catering_cost,
"get_booking_deadline": get_booking_deadline,
}
def run_agent(task: str, max_turns: int = 8) -> str:
messages = [{"role": "user", "content": task}]
for turn in range(max_turns):
resp = client.chat.completions.create(
model="Qwen/Qwen3-235B-A22B-Instruct-2507",
messages=messages, tools=TOOLS, max_tokens=400
)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
fn = TOOL_MAP.get(tc.function.name)
result = fn(**args) if fn else {"success": False,
"error": f"Unknown tool: {tc.function.name}"}
messages.append({"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(result)})
return "Max turns reached."Run against "For tonight's event, check The Albanach and The Haymarket Vaults for 160 guests with vegan options, and estimate catering at £35/head," the model checked both venues in a single response turn — parallel tool calls, the 2026 default, sequential only when one output depends on another. TOOL_MAP is the whole dispatcher: one fn(**args) if fn else error block handles every tool, so no if/elif chain grows with each new capability. And because every tool follows lesson 07's return-contract principle, {"success": False, "error": "Venue not found"} lets the model say "that pub returned not found, I'll try another option" — an unhandled exception would have ended the loop outright instead.
What carries forward
CLI and functions are the ground floor. Lesson 09 takes this same JSON-in/Python-executes/JSON-out loop and moves it across a network boundary — external APIs first, then MCP servers, which are that same loop with the schema published by the server instead of hardcoded by you.
External APIs and MCP servers: the same loop, now over a network, and the one ownership question that decides which of the two you build.
Reply here and it goes straight to Rod. Same as replying to one of his emails.