So far, our agent has been a Python script with the task hard-coded at the bottom. That was useful while we were learning the loop, tools, harness, workspace, and permissions.
But real tools need an interface. For a coding agent, the first good interface is usually a CLI.
.venv/bin/python loop.py \
"Read notes.txt and write a short summary to summary.txt"
Why A CLI?
A CLI is not the universal best interface for every agent. It is a good first interface for a coding agent because the primary user is an engineer, and engineers already live close to the terminal, the repository, the test runner, and Git.
This matters because a coding agent often needs a precise workspace, shell output, approval prompts, exit codes, repeatable commands, and a way to fit into existing development workflows. The terminal is already where many of those things meet.
engineer workflow:
open repo
run command
inspect diff
run tests
commit or discard
For a different kind of agent, a CLI might be the wrong product surface. A customer-support agent may belong in a helpdesk UI. A research agent may work better as a web workspace with sources and citations. A monitoring agent may be a background job with alerts. A personal assistant may want chat, voice, calendar UI, or mobile notifications. However, the building blocks are exactly the same.
possible agent surfaces:
CLI
chat UI
IDE extension
web dashboard
background worker
API endpoint
scheduled automation
So we are not choosing a CLI because agents must be CLIs. We are choosing it because this agent is a coding agent, and the CLI makes the important engineering controls visible early.
What The CLI Owns
The CLI is not the model and it is not the tool implementation. It is the product surface around the harness.
CLI owns:
task input
workspace selection
approval prompts
run output
exit codes
session rules
Task As Input
The first CLI feature is simple: the user should pass the task as an argument instead of editing the Python file.
parser.add_argument("task", help="The task for the agent to perform.")
With this tiny change, we can run many tasks against the same harness.
Workspace As A Flag
The second feature is the workspace. In the previous
post, the workspace was Path.cwd(). That is
a good default, but a CLI should make the boundary
visible.
.venv/bin/python loop.py \
--workspace ~/Projects/demo \
"Inspect the README and summarize the project"
Passing the workspace explicitly keeps the user aware of where the agent is allowed to act. It also makes testing safer because you can point the loop at a throwaway folder.
Approvals Become Interaction
In the permissions post, policy could return
ask. Until now, that was only data. The CLI
is where ask becomes an actual user
interaction.
The agent wants to modify source code: src/app.py
Risk: changes code that may affect behavior
Allow?
y yes, once
s yes, for this session
n no
Notice that this is still not a fancy interface. It does not need to be. It only needs to make the decision clear at the moment the decision matters.
Session Rules
If the user approves an action for the session, the CLI needs somewhere to remember that approval. For now, an in-memory set is enough.
session_approvals = set()
if rule_hint in session_approvals:
return {"ok": True}
We are not building persistent "always allow" rules yet. That would require a config file, a way to inspect saved rules, and a way to remove them. For the first CLI, session approvals are enough to show the pattern without making the product harder to reason about.
Output Is Part Of The Product
A CLI should not dump every internal message unless the user asks for debug output. Normal output should answer three questions.
What did the agent do?
Why did it stop?
What should I inspect next?
The harness already has the data: tool calls, tool results, stop reason, and final assistant message. The CLI chooses how much of that to show.
Exit Codes Matter
A CLI is also something scripts can call. That means exit codes matter.
0 final answer or completed run
1 blocked by policy or user denial
2 tool budget or turn budget exceeded
3 runtime/configuration error
This is another small product decision. Exit codes make the loop usable inside shell scripts, CI experiments, and future test harnesses.
The Loop So Far
Here is a single-file version with a CLI, workspace
flag, approval prompt, and session approvals. Keep LM
Studio running, create a small test workspace, and copy
this as
loop.py.
import argparse
import json
import os
import sys
from pathlib import Path
from openai import OpenAI
BASE_URL = os.getenv("LOOP_BASE_URL", "http://localhost:1234/v1")
API_KEY = os.getenv("LOOP_API_KEY", "lm-studio")
MODEL = os.getenv("LOOP_MODEL", "qwen/qwen3-coder-30b")
MAX_TURNS = 8
MAX_TOOL_CALLS = 12
client = OpenAI(base_url=BASE_URL, api_key=API_KEY)
session_approvals: set[str] = set()
WORKSPACE = Path.cwd().resolve()
IGNORED_NAMES = {
".git",
".venv",
"__pycache__",
"node_modules",
"dist",
"build",
}
SENSITIVE_PATHS = (".env", "secrets/")
INTERNAL_PATHS = (".loop/",)
SOURCE_PATHS = ("src/", "tests/")
class ToolError(Exception):
def __init__(self, error_type: str, message: str, retryable: bool):
self.error_type = error_type
self.message = message
self.retryable = retryable
def safe_path(path: str) -> Path:
resolved = (WORKSPACE / path).resolve()
if not resolved.is_relative_to(WORKSPACE):
raise ToolError(
"permission",
"Path escapes the workspace.",
retryable=False,
)
return resolved
def path_starts_with(path: str, prefixes: tuple[str, ...]) -> bool:
return any(
path == prefix.rstrip("/") or path.startswith(prefix)
for prefix in prefixes
)
def allow() -> dict:
return {"decision": "allow"}
def deny(message: str) -> dict:
return {
"decision": "deny",
"message": message,
"retryable": False,
}
def ask(message: str, risk: str, rule_hint: str) -> dict:
return {
"decision": "ask",
"message": message,
"risk": risk,
"rule_hint": rule_hint,
"approval_scopes": ["once", "session"],
"retryable": False,
}
def list_dir(path: str) -> list[str]:
target = safe_path(path)
return sorted(
p.name
for p in target.iterdir()
if p.name not in IGNORED_NAMES
)
def read_file(path: str) -> str:
target = safe_path(path)
return target.read_text(encoding="utf-8")
def write_file(path: str, content: str) -> str:
target = safe_path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return f"Wrote {path}"
TOOL_FUNCTIONS = {
"list_dir": list_dir,
"read_file": read_file,
"write_file": write_file,
}
REQUIRED_ARGUMENTS = {
"list_dir": ["path"],
"read_file": ["path"],
"write_file": ["path", "content"],
}
tools = [
{
"type": "function",
"function": {
"name": "list_dir",
"description": (
"List visible files and folders inside the workspace. "
"Use this to inspect project structure. "
"Path must be relative to the workspace."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory path relative to the workspace.",
}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "read_file",
"description": (
"Read a UTF-8 text file from the workspace. "
"Use this when you need exact file contents. "
"Path must be relative to the workspace."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path relative to the workspace.",
}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": (
"Write a UTF-8 text file inside the workspace. "
"Use this only when the task requires changing or creating a file. "
"Path must be relative to the workspace."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path relative to the workspace.",
},
"content": {
"type": "string",
"description": "Complete text content to write.",
},
},
"required": ["path", "content"],
},
},
},
]
def parse_arguments(raw_arguments: str) -> dict:
try:
return json.loads(raw_arguments or "{}")
except json.JSONDecodeError as error:
raise ToolError(
"invalid_tool_arguments",
f"Arguments were not valid JSON: {error}",
retryable=True,
)
def require(arguments: dict, *names: str) -> None:
missing = [name for name in names if name not in arguments]
if missing:
raise ToolError(
"missing_arguments",
f"Missing required arguments: {', '.join(missing)}",
retryable=True,
)
def validate_tool_call(name: str, arguments: dict) -> None:
if name not in REQUIRED_ARGUMENTS:
raise ToolError("unknown_tool", f"Unknown tool: {name}", False)
require(arguments, *REQUIRED_ARGUMENTS[name])
def check_policy(name: str, arguments: dict) -> dict:
path = arguments.get("path", "")
if name == "read_file" and path_starts_with(path, SENSITIVE_PATHS):
return deny("The agent may not read sensitive files.")
if name == "write_file" and path_starts_with(path, INTERNAL_PATHS):
return deny("The agent may not write internal loop state.")
if name == "write_file" and path_starts_with(path, SENSITIVE_PATHS):
return deny("The agent may not write secrets.")
if name == "write_file" and path_starts_with(path, SOURCE_PATHS):
return ask(
message=f"The agent wants to modify source code: {path}",
risk="changes code that may affect behavior",
rule_hint=f"write_file:{path}",
)
return allow()
def prompt_for_approval(policy: dict) -> bool:
rule_hint = policy.get("rule_hint")
if rule_hint in session_approvals:
return True
print("\nApproval needed")
print(policy["message"])
print(f"Risk: {policy.get('risk', 'unknown')}")
print("\nAllow?")
print(" y yes, once")
print(" s yes, for this session")
print(" n no")
choice = input("> ").strip().lower()
if choice == "s" and rule_hint:
session_approvals.add(rule_hint)
return True
return choice == "y"
def policy_to_tool_result(name: str, policy: dict) -> dict:
return {
"ok": False,
"tool": name,
"error_type": "permission",
"message": policy["message"],
"retryable": policy.get("retryable", False),
"needs_approval": policy["decision"] == "ask",
"risk": policy.get("risk"),
"rule_hint": policy.get("rule_hint"),
"approval_scopes": policy.get("approval_scopes", []),
}
def normalize_result(name: str, result) -> dict:
return {"ok": True, "tool": name, "result": result}
def error_result(name: str, error: ToolError) -> dict:
return {
"ok": False,
"tool": name,
"error_type": error.error_type,
"message": error.message,
"retryable": error.retryable,
}
def execute_tool(name: str, arguments: dict):
return TOOL_FUNCTIONS[name](**arguments)
def handle_tool_call(tool_call) -> dict:
name = tool_call.function.name
try:
arguments = parse_arguments(tool_call.function.arguments)
validate_tool_call(name, arguments)
policy = check_policy(name, arguments)
if policy["decision"] == "deny":
return policy_to_tool_result(name, policy)
if policy["decision"] == "ask" and not prompt_for_approval(policy):
return policy_to_tool_result(name, policy)
result = execute_tool(name, arguments)
return normalize_result(name, result)
except ToolError as error:
return error_result(name, error)
def log_event(event: dict) -> None:
print(json.dumps(event))
def call_model(messages: list[dict]):
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools,
tool_choice="auto",
)
return response.choices[0].message
def run_agent(task: str) -> dict:
messages = [
{
"role": "system",
"content": (
"You are a tiny coding agent. Use tools when you need to inspect "
"or change the workspace. Paths must stay inside the workspace. "
"When you have enough information, answer clearly."
),
},
{"role": "user", "content": task},
]
tool_call_count = 0
stop_reason = "max_turns"
for turn in range(MAX_TURNS):
log_event({"type": "model_call", "turn": turn})
message = call_model(messages)
messages.append(message.model_dump(exclude_none=True))
if message.content:
print("\nassistant:")
print(message.content)
if not message.tool_calls:
stop_reason = "final_answer"
break
for tool_call in message.tool_calls:
tool_call_count += 1
if tool_call_count > MAX_TOOL_CALLS:
stop_reason = "tool_budget_exceeded"
break
result = handle_tool_call(tool_call)
log_event(
{
"type": "tool_result",
"turn": turn,
"tool": result.get("tool"),
"ok": result["ok"],
"error_type": result.get("error_type"),
}
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result),
}
)
if stop_reason != "max_turns":
break
return {
"messages": messages,
"stop_reason": stop_reason,
"tool_call_count": tool_call_count,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the tiny coding agent loop.")
parser.add_argument("task", help="The task for the agent to perform.")
parser.add_argument(
"--workspace",
default=".",
help="Workspace directory the agent can inspect and edit.",
)
return parser.parse_args()
def main() -> int:
global WORKSPACE
args = parse_args()
WORKSPACE = Path(args.workspace).expanduser().resolve()
if not WORKSPACE.exists() or not WORKSPACE.is_dir():
print(f"Workspace does not exist: {WORKSPACE}", file=sys.stderr)
return 3
print(f"Workspace: {WORKSPACE}")
result = run_agent(args.task)
print(f"\nStopped because: {result['stop_reason']}")
if result["stop_reason"] == "final_answer":
return 0
if result["stop_reason"] in {"max_turns", "tool_budget_exceeded"}:
return 2
return 1
if __name__ == "__main__":
raise SystemExit(main())
Try It
Create a small test folder first. Do not point the agent at an important project while you are still learning.
mkdir cli-test
echo "Loop is becoming a CLI." > cli-test/notes.txt
.venv/bin/python loop.py \
--workspace cli-test \
"Read notes.txt and write a short summary to summary.txt"
If the model writes summary.txt, it should
happen without approval. If you ask it to edit something
under src/, the CLI should stop and ask.
Where we are
The CLI turns the loop into a usable tool. It gives the user a place to provide a task, choose a workspace, approve risky actions, and understand why the run stopped.
The next problem is project context. Right now, each task starts from the user's prompt and whatever files the model chooses to inspect. Soon we will give the agent a better way to understand the project before it starts acting.