Skip to content
Blog

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 July 30, 2026

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 PhaseCode LocationWhat It Does
Observestate.messages accumulated historyHolds prior observations, tool output, and context
Reasonself.llm_call(messages)Single LLM call returning thoughts and tool decisions
Actself.tools.execute(name, params)Executes tool calls via ToolRegistry through SafetyGate
Evaluateself.budget.check(state)Enforces hard step, token, and cost budget limits

Key Takeaways

  1. Observe → Reason → Act → Evaluate maps directly to code.
  2. Use a ToolRegistry to expose structured tool specs and safe execution.
  3. Hard runtime guards (SafetyGate & BudgetGuard) must operate independently of the LLM.
  4. Custom agent loops give full control over state, persistence, and non-standard termination criteria.