Build a Custom Autonomous Coding Agent from Scratch
Build a production-ready autonomous coding agent loop in Python implementing the ORAE cycle, tool registries, safety gates, and budget guards.
Published on • 2026-07-30
AI Assistant

Frameworks like LangGraph and CrewAI cover common cases. When your requirements are unique — specialized tools, custom evaluation logic, non-standard termination criteria, or tight integration with a proprietary system — you need to build the loop yourself.
This article builds a minimal but complete autonomous coding agent in Python. Every line in this implementation corresponds to a principle of loop engineering: the ORAE cycle, state management, feedback, safety, and termination.
Architecture
The agent is composed of five subsystems:
flowchart TB
subgraph CodingAgentLoop
direction TB
subgraph Row1[" "]
direction LR
subgraph ToolRegistry["ToolRegistry"]
direction TB
TR1["register()"]
TR2["execute()"]
end
subgraph LLMClient["LLMClient"]
direction TB
LC1["generate()"]
end
subgraph BudgetGuard["BudgetGuard"]
direction TB
BG1["check()"]
end
end
subgraph Row2[" "]
direction LR
subgraph SafetyGate["SafetyGate"]
direction TB
SG1["check()"]
end
subgraph AgentState["AgentState"]
direction TB
AS1["messages"]
AS2["step"]
AS3["history"]
end
end
end
%% Hide layout helper rows
style Row1 fill:none,stroke:none
style Row2 fill:none,stroke:none
%% Main component style
classDef component fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,color:#0f172a;
%% Method / Property style
classDef member fill:#fef3c7,stroke:#d97706,stroke-width:1.5px,color:#0f172a;
%% Container style
style CodingAgentLoop fill:#f8fafc,stroke:#64748b,stroke-width:2px
%% Apply styles to components
class ToolRegistry,LLMClient,BudgetGuard,SafetyGate,AgentState component;
%% Apply styles to methods/properties
class TR1,TR2,LC1,BG1,SG1,AS1,AS2,AS3 member;
Python Implementation
Core Data Structures & Tool Registry
import json
import time
from dataclasses import dataclass, field
from typing import Callable
from datetime import datetime
@dataclass
class ToolSpec:
name: str
description: str
parameters: dict # JSON schema
fn: Callable
permission_level: str = "safe"
@dataclass
class AgentConfig:
model: str = "claude-3-sonnet"
max_steps: int = 25
max_tokens: int = 100_000
max_cost: float = 2.00
system_prompt: str = ""
allowed_tools: list[str] | None = None
@dataclass
class StepRecord:
step: int
timestamp: str
reasoning: str
action: str | None
action_params: dict | None
observation: str | None
token_count: int
cost: float
@dataclass
class AgentState:
messages: list[dict] = field(default_factory=list)
step: int = 0
total_tokens: int = 0
total_cost: float = 0.0
history: list[StepRecord] = field(default_factory=list)
final_output: str | None = None
status: str = "running"
start_time: float = field(default_factory=time.time)
class ToolRegistry:
def __init__(self):
self._tools: dict[str, ToolSpec] = {}
def register(self, spec: ToolSpec):
self._tools[spec.name] = spec
def get(self, name: str) -> ToolSpec | None:
return self._tools.get(name)
def list_tools(self) -> list[dict]:
return [
{"name": t.name, "description": t.description, "parameters": t.parameters}
for t in self._tools.values()
]
def execute(self, name: str, params: dict) -> str:
spec = self._tools.get(name)
if not spec:
return json.dumps({"error": f"Unknown tool: {name}"})
try:
result = spec.fn(**params)
return json.dumps({"result": result})
except Exception as e:
return json.dumps({"error": str(e)})
Safety Gate & Budget Guard
class BudgetGuard:
def __init__(self, config: AgentConfig):
self.config = config
def check(self, state: AgentState) -> bool:
if state.step >= self.config.max_steps:
state.status = "terminated: max_steps"
return False
if state.total_tokens >= self.config.max_tokens:
state.status = "terminated: max_tokens"
return False
if state.total_cost >= self.config.max_cost:
state.status = "terminated: max_cost"
return False
return True
class SafetyGate:
def __init__(self, registry: ToolRegistry, allowed_tools: list[str] | None = None):
self.registry = registry
self.allowed = allowed_tools
def check(self, tool_name: str, params: dict) -> str | None:
if self.allowed and tool_name not in self.allowed:
return f"Tool '{tool_name}' is not in the allowed list"
spec = self.registry.get(tool_name)
if not spec:
return f"Unknown tool: {tool_name}"
return None
The ORAE Loop Engine
class CodingAgentLoop:
def __init__(self, config: AgentConfig):
self.config = config
self.tools = ToolRegistry()
self.budget = BudgetGuard(config)
self.gate = SafetyGate(self.tools, config.allowed_tools)
def run(self, task: str) -> AgentState:
state = AgentState()
state.messages = [
{"role": "system", "content": self.config.system_prompt},
{"role": "user", "content": task},
]
while state.status == "running":
if not self.budget.check(state):
break
# ── Observe & Reason ──
# (In production, call LLM client here)
response = self.llm_call(state.messages)
state.total_tokens += response["token_count"]
state.total_cost += response["cost"]
reasoning = response["content"]
tool_calls = response.get("tool_calls", [])
# ── Act Phase ──
if tool_calls:
for tc in tool_calls:
block_reason = self.gate.check(tc["name"], tc["args"])
if block_reason:
observation = json.dumps({"error": "BLOCKED", "reason": block_reason})
else:
observation = self.tools.execute(tc["name"], tc["args"])
state.messages.append({"role": "assistant", "content": reasoning})
state.messages.append({"role": "tool", "content": observation})
else:
state.final_output = reasoning
state.status = "completed"
state.step += 1
return state
ORAE Cycle Mapping
| ORAE Phase | Code Location | What It Does |
|---|---|---|
| Observe | state.messages accumulated history | Holds prior observations, tool output, and context |
| Reason | self.llm_call(messages) | Single LLM call returning thoughts and tool decisions |
| Act | self.tools.execute(name, params) | Executes tool calls via ToolRegistry through SafetyGate |
| Evaluate | self.budget.check(state) | Enforces hard step, token, and cost budget limits |
Key Takeaways
- Observe → Reason → Act → Evaluate maps directly to code.
- Use a ToolRegistry to expose structured tool specs and safe execution.
- Hard runtime guards (SafetyGate & BudgetGuard) must operate independently of the LLM.
- Custom agent loops give full control over state, persistence, and non-standard termination criteria.