Skip to content

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.

Before you start

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.

Rule 1: no shell=True

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.

Rule 2: allowlist subcommands

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.

Rule 3: validate and resolve paths

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.

Rule 4: set timeout and output limits

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.

Files changed
venues.txt
search_venue_notes() wrapper
CLI tool schema
python
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.

Terminal
$
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.

One dispatch loop, wearing three different costumes

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.

Feature20232026
Parallel callsOne tool per turnMultiple tools simultaneously
Tool choiceModel decidestool_choice="required" forces it
Schema validationSoft, best-effort JSONstrict: true, guaranteed valid
Thinking plus toolsSeparateInterleaved, 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".

python
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.

Quick check — A team says: 'We only use in-process functions, never CLI wrappers, because functions are the safe paradigm.' What does this lesson's own evidence say about that claim?

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.

Continue to Lesson 09

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.

Have a question about this lesson?

Reply here and it goes straight to Rod. Same as replying to one of his emails.