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