Building a claude opus 4.8 coding agent is less about prompting tricks and more about giving the model a tight, observable loop over a real filesystem and shell. In this tutorial we stand up a minimal agent that can read files, run tests, and patch code using the model’s native tool-calling via an OpenAI-compatible endpoint. You’ll end with a runnable Python script that drives a real coding task end to end, and a clear picture of where to harden it before production.
Prerequisites
- Python 3.11 or newer. The
subprocesstimeout behavior andmodel_dumphelper we use are cleanest on 3.11+. openaiPython package (>=1.40):pip install openai.- An API key for a gateway that exposes Claude Opus 4.8. We’ll point the OpenAI client at a single OpenAI-compatible endpoint; n4n.ai fronts 240+ models including this one and handles provider fallback automatically when a backend is rate-limited.
- A throwaway working directory. The agent will execute shell commands, so never point it at a directory you care about. A temp dir is fine.
export OPENAI_API_KEY="sk-..."
export BASE_URL="https://api.n4n.ai/v1"
export WORKDIR="/tmp/agent_sandbox"
mkdir -p "$WORKDIR"
If you use a different gateway, just change BASE_URL and the model slug. The code below assumes the OpenAI-compatible contract: same request/response shapes, tools as function schemas.
The agent loop in practice
The pattern is boring and that’s correct:
- Send system prompt + conversation to model with a tool schema.
- Model returns either a final message or one or more
tool_calls. - You execute the calls locally, capture stdout/stderr/result, and append them as
toolmessages. - Repeat until the model stops requesting tools or you hit a step cap.
No autonomous recursion beyond a max step count. For a coding agent, 20–30 steps is plenty for a single focused task like fixing a test or scaffolding a module. If you need more, the task is underspecified.
Define the tool schema
Claude Opus 4.8 accepts JSON-schema tool definitions through the OpenAI-compatible /chat/completions interface. We expose three primitives: read a file, write a file, run a shell command. Keep the schema strict—the model follows descriptions literally.
TOOLS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a UTF-8 text file relative to the workdir. Returns contents.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Relative path"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write content to a file, overwriting if present.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "run_shell",
"description": "Run a shell command in the workdir. Returns combined output.",
"parameters": {
"type": "object",
"properties": {
"cmd": {"type": "string"},
"timeout": {"type": "integer", "default": 30},
},
"required": ["cmd"],
},
},
},
]
Implement sandboxed execution
Keep the agent inside WORKDIR. Use subprocess.run with cwd, capture output, enforce a timeout. This is not a security boundary against malicious model output—just a footgun guard so it can’t rm -rf your home directory.
import os, subprocess, json
WORKDIR = os.environ.get("WORKDIR", "/tmp/agent_sandbox")
def read_file(path: str) -> str:
full = os.path.join(WORKDIR, path)
with open(full, "r", encoding="utf-8") as f:
return f.read()
def write_file(path: str, content: str) -> str:
full = os.path.join(WORKDIR, path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
return f"wrote {len(content)} bytes to {path}"
def run_shell(cmd: str, timeout: int = 30) -> str:
try:
res = subprocess.run(
cmd, shell=True, cwd=WORKDIR, capture_output=True,
text=True, timeout=timeout
)
out = res.stdout + res.stderr
return f"exit={res.returncode}\n{out[:4000]}"
except subprocess.TimeoutExpired:
return "exit=timeout"
Wire the client
We use the OpenAI Python client but swap base_url. The model name follows the gateway’s routing convention; for Claude Opus 4.8 it’s typically anthropic/claude-opus-4.8. If your gateway honors client routing directives, you can pin a provider or region in the same field.
from openai import OpenAI
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ["BASE_URL"],
)
MODEL = "anthropic/claude-opus-4.8"
The loop
We keep a messages list. System prompt is terse and mandates tool use for any file access. Note the exclude_none=True on model_dump—the OpenAI client objects include None fields that some gateways reject.
SYSTEM = """You are a coding agent. Use the provided tools to inspect and modify code in the workdir.
Do not guess file contents. When the task is complete, reply with a final summary and no tool calls.
"""
def run_agent(task: str, max_steps: int = 25):
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": task},
]
for step in range(max_steps):
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
msg = resp.choices[0].message
if not msg.tool_calls:
print("FINAL:", msg.content)
return msg.content
messages.append(msg.model_dump(exclude_none=True))
for call in msg.tool_calls:
fn = call.function.name
args = json.loads(call.function.arguments)
if fn == "read_file":
result = read_file(args["path"])
elif fn == "write_file":
result = write_file(args["path"], args["content"])
elif fn == "run_shell":
result = run_shell(args.get("cmd"), args.get("timeout", 30))
else:
result = "unknown tool"
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
return "step limit reached"
Expected first checkpoint output when run with a trivial task like “list files in workdir”:
FINAL: I inspected the directory and found no source files. The workdir is empty.
A real task: fix a failing test
Seed the sandbox with a buggy module and a pytest file.
cat > "$WORKDIR/calc.py" <<'PY'
def add(a, b):
return a - b # bug: should be +
PY
cat > "$WORKDIR/test_calc.py" <<'PY'
from calc import add
def test_add():
assert add(2, 3) == 5
PY
Now drive the agent:
task = (
"The test test_calc.py fails. Read both files, fix calc.py so the test "
"passes, then run pytest to confirm. Report the final diff."
)
run_agent(task)
A competent claude opus 4.8 coding agent will typically:
read_fileoncalc.pyandtest_calc.py.write_fileto correct the operator.run_shellpytest -q.- Return a final summary with the diff.
Sample transcript excerpt:
tool_call: read_file path=calc.py
tool_result: def add(a, b):\n return a - b
tool_call: write_file path=calc.py content="def add(a, b):\n return a + b\n"
tool_result: wrote 28 bytes to calc.py
tool_call: run_shell cmd="pytest -q"
tool_result: exit=0\n1 passed in 0.01s
FINAL: Fixed calc.py by changing '-' to '+'. pytest now passes (1 passed).
Tool design principles
The three tools above are deliberately narrow. Wide tools (“edit code”) invite the model to emit malformed patches. Narrow tools with explicit feedback loops are easier to debug: every action is a line in the transcript. If you need edits, prefer write_file with full content over sed invocations—the model is better at regenerating a file than at counting lines.
Also, never return huge file contents in run_shell without truncation. We cap at 4000 chars; the model can ask for specific files via read_file if it needs more.
Debugging the agent
When the loop misbehaves, print messages as JSON after each step. The two common failure modes:
- Schema drift: The model passes
argswith extra keys.json.loadsignores them, but your handler must default missing values. - Silent hallucination: The model claims a file exists without reading it. The system prompt rule “do not guess” mitigates this, but you can also assert that
read_filewas called beforewrite_fileon the same path in your orchestrator.
Hardening for real use
The script above is a starting point, not a product. Concrete improvements we’ve shipped:
- Command allowlist. Parse
cmdand reject anything not in a small set (pytest,python,ls,cat). The model rarely needs more for code tasks. - Diff-based writes. Instead of full file overwrite, have the agent emit unified diffs and apply with
patch. This limits blast radius and makes reviews trivial. - Token accounting. Gateways meter per-token usage; log
resp.usageeach step so you can attribute cost per task. A long agent loop can silently burn tokens on repeated reads. - System prompt caching. If your gateway forwards provider cache-control hints, mark the static system prompt and tool schema as cached to cut latency and cost on long loops.
- Deterministic stop. Never let the loop run unbounded. A
max_stepscounter is the cheapest reliability win.
Why this shape works
Claude Opus 4.8 handles multi-step tool sequences well when each step’s result is fed back verbatim. The failure mode is silent hallucination of file state—hence the strict “no guessing, always read” rule in the system prompt. Keeping tools side-effect-free except inside the sandbox makes the agent debuggable: every action is a line in the transcript.
If you need to scale this beyond a single process, move tool execution behind a queue and keep the model loop single-threaded. The model is the bottleneck, not your shell.
That’s the whole machine: a tight loop, three tools, and an OpenAI-compatible client pointed at Claude Opus 4.8. Extend the tool schema before you extend the prompt.