Back to Tutorials
tutorialstutorialai

How to Build an AI Development Agent with OpenHands

Practical tutorial: It introduces an AI-driven development framework, which is useful but not groundbreaking.

BlogIA AcademyJune 24, 202613 min read2 420 words

How to Build an AI Development Agent with OpenHands

Table of Contents

📺 Watch: Neural Networks Explained

Video by 3Blue1Brown


You're staring at a terminal, waiting for a code review that's three hours late. Your CI pipeline just failed on a linting issue you've seen a dozen times. The pull request template asks for test coverag [3]e, but writing tests feels like busywork when you're already behind.

This is where AI-driven development frameworks change the workflow. Instead of treating AI as a chatbot that generates code snippets, you can run an autonomous agent that handles the entire development loop: writing code, running tests, fixing failures, and committing changes.

OpenHands is one such framework. As of June 2026, it has 68,977 stars on GitHub and 8,623 forks, making it one of the most popular open-source projects in the LLM category. It's written in Python and its description is "🙌 OpenHands: AI-Driven Development." This tutorial walks through building a production-ready agent that can take a GitHub issue and deliver a working pull request.

Architecture: Why OpenHands Works Differently Than Chat-Based AI

Most developers interact with LLMs through a chat interface. You paste code, ask for changes, copy the response back. This is slow and error-prone. OpenHands flips the model: the AI gets a sandboxed environment, a task description, and the ability to execute commands, read files, and write code autonomously.

The architecture follows a loop pattern:

  1. The agent receives a task (e.g., "Fix the pagination bug in users.py")
  2. It explores the codebase by reading files and running grep
  3. It formulates a plan and writes code
  4. It runs tests and linters
  5. If tests fail, it reads the error output and iterates
  6. Once tests pass, it creates a commit and pull request

This matters because it removes the human bottleneck. Instead of you being the middleman between the LLM and your codebase, the agent works directly. The tradeoff is control: you need to trust the agent's changes, which is why OpenHands runs in a Docker container with network restrictions.

Prerequisites and Environment Setup

Before writing any agent code, set up the infrastructure. You need Docker, Python 3.11+, and a GitHub personal access token with repo scope.

# Install Docker on Ubuntu/Debian
sudo apt-get update
sudo apt-get install docker.io docker-compose-v2
sudo usermod -aG docker $USER
# Log out and back in for group changes to take effect

# Verify Docker works
docker run hello-world

# Install OpenHands from source
git clone https://github.com/All-Hands-AI/OpenHands.git
cd OpenHands
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt

The requirements file pins specific versions of LangChain, Pydantic, and the OpenAI [8] SDK. If you're running on a machine without a GPU, the agent will still work because it calls the LLM API remotely. The local compute is only for running tests and linting.

Set your environment variables:

export OPENAI_API_KEY="sk-your-key-here"
export GITHUB_TOKEN="ghp_your-token-here"
export WORKSPACE_BASE="/tmp/openhands_workspace"

The WORKSPACE_BASE directory is mounted into the Docker container. The agent writes code here, runs tests, and stores logs. Make sure this directory has at least 10GB of free space for larger repositories.

Core Implementation: Building a Task-Driven Agent

The agent needs three components: a task parser that converts GitHub issues into structured prompts, a sandbox executor that runs code safely, and a feedback loop that handles test failures.

Step 1: Parse the GitHub Issue

GitHub issues are unstructured text. The agent needs to extract the problem description, expected behavior, and any code references. Here's a parser that handles the common formats:

import re
from typing import Optional, Dict, List
from pydantic import BaseModel

class GitHubIssue(BaseModel):
    title: str
    body: str
    labels: List[str]
    repo_name: str
    issue_number: int

class ParsedTask(BaseModel):
    problem_statement: str
    expected_behavior: str
    affected_files: List[str]
    test_requirements: List[str]

def parse_github_issue(issue: GitHubIssue) -> ParsedTask:
    """
    Extract structured task from unstructured issue text.
    Handles common patterns like "Steps to reproduce" and "Expected behavior".
    """
    body = issue.body

    # Extract expected behavior section
    expected_match = re.search(
        r'(?:Expected behavior|Expected|What should happen)[:\s]*\n(.*?)(?:\n\n|$)',
        body,
        re.DOTALL | re.IGNORECASE
    )
    expected_behavior = expected_match.group(1).strip() if expected_match else ""

    # Extract steps to reproduce
    steps_match = re.search(
        r'(?:Steps to reproduce|To Reproduce|Steps)[:\s]*\n(.*?)(?:\n\n|$)',
        body,
        re.DOTALL | re.IGNORECASE
    )
    problem_statement = steps_match.group(1).strip() if steps_match else body[:500]

    # Find file references like `path/to/file.py:42`
    file_refs = re.findall(r'`?([a-zA-Z0-9_/.-]+\.(?:py|js|ts|jsx|tsx))`?', body)
    affected_files = list(set(file_refs))

    # Check for test labels
    test_labels = [l for l in issue.labels if 'test' in l.lower()]
    test_requirements = [
        "Write unit tests for the fix" if 'test' in issue.labels else "Verify existing tests pass"
    ]

    return ParsedTask(
        problem_statement=problem_statement,
        expected_behavior=expected_behavior,
        affected_files=affected_files,
        test_requirements=test_requirements
    )

The regex patterns handle the most common GitHub issue templates. The edge case is when users don't follow templates—then the parser falls back to the first 500 characters of the body. This isn't perfect, but it gives the agent enough context to start exploring.

Step 2: Configure the Sandbox Executor

The sandbox runs the agent's commands inside a Docker container. This prevents the agent from modifying system files or accessing sensitive data. OpenHands uses a custom Docker image with common development tools pre-installed.

import docker
import os
import tempfile
from pathlib import Path

class SandboxExecutor:
    """
    Runs agent commands in an isolated Docker container.
    Mounts the workspace directory and restricts network access.
    """

    def __init__(self, workspace_base: str = "/tmp/openhands_workspace"):
        self.client = docker.from_env()
        self.workspace_base = Path(workspace_base)
        self.workspace_base.mkdir(parents=True, exist_ok=True)
        self.container = None

    def start(self, repo_url: str):
        """
        Start the sandbox container and clone the repository.
        """
        image = "ghcr.io/all-hands-ai/openhands-sandbox:latest"

        # Pull the image if not cached
        try:
            self.client.images.get(image)
        except docker.errors.ImageNotFound:
            print(f"Pulling sandbox image: {image}")
            self.client.images.pull(image)

        # Create container with resource limits
        self.container = self.client.containers.run(
            image,
            detach=True,
            volumes={
                str(self.workspace_base): {
                    'bind': '/workspace',
                    'mode': 'rw'
                }
            },
            mem_limit="4g",
            cpu_period=100000,
            cpu_quota=50000,  # Half a CPU core
            network_mode="none",  # No network access by default
            working_dir="/workspace",
            command="tail -f /dev/null"  # Keep container alive
        )

        # Clone the repository
        self._execute_command(f"git clone {repo_url} /workspace/repo")

    def _execute_command(self, command: str) -> tuple[str, str, int]:
        """
        Execute a command inside the sandbox and return stdout, stderr, exit code.
        """
        if not self.container:
            raise RuntimeError("Sandbox not started. Call start() first.")

        exec_result = self.container.exec_run(
            ["/bin/bash", "-c", command],
            demux=True
        )

        stdout = exec_result.output[0].decode() if exec_result.output[0] else ""
        stderr = exec_result.output[1].decode() if exec_result.output[1] else ""

        return stdout, stderr, exec_result.exit_code

    def run_tests(self, test_command: str = "pytest") -> tuple[bool, str]:
        """
        Run tests and return (passed, output).
        """
        stdout, stderr, exit_code = self._execute_command(
            f"cd /workspace/repo && {test_command} 2>&1"
        )

        passed = exit_code == 0
        output = stdout + stderr

        return passed, output

    def cleanup(self):
        """
        Stop and remove the container.
        """
        if self.container:
            self.container.stop()
            self.container.remove()
            self.container = None

The sandbox has no network access by default. This is intentional—it prevents the agent from downloading arbitrary packages or exfiltrating data. If your project needs pip installs, you'll need to whitelist specific package indices.

Step 3: Implement the Feedback Loop

The core loop reads the task, executes commands, checks results, and iterates. This is where the agent decides whether to fix a test failure or escalate to the user.

import json
from openai import OpenAI
from typing import Optional

class DevelopmentAgent:
    """
    Autonomous agent that fixes issues by writing code, running tests, and iterating.
    """

    def __init__(self, api_key: str, model: str = "gpt [7]-4o"):
        self.client = OpenAI(api_key=api_key)
        self.model = model
        self.sandbox = SandboxExecutor()
        self.max_iterations = 10
        self.conversation_history = []

    def _call_llm(self, system_prompt: str, user_prompt: str) -> str:
        """
        Call the LLM with conversation history for context.
        """
        messages = [
            {"role": "system", "content": system_prompt},
            *self.conversation_history[-5:],  # Keep last 5 turns for context
            {"role": "user", "content": user_prompt}
        ]

        response = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            temperature=0.2,  # Low temperature for deterministic code
            max_tokens=4096
        )

        content = response.choices[0].message.content

        # Update conversation history
        self.conversation_history.append({"role": "user", "content": user_prompt})
        self.conversation_history.append({"role": "assistant", "content": content})

        return content

    def _extract_code_blocks(self, text: str) -> list[dict]:
        """
        Extract code blocks from LLM response with their file paths.
        Expects format: ```python:path/to/file.py
        """
        pattern = r'```(\w+):([^\n]+)\n(.*?)```'
        matches = re.findall(pattern, text, re.DOTALL)

        blocks = []
        for lang, filepath, code in matches:
            blocks.append({
                'language': lang,
                'filepath': filepath.strip(),
                'code': code.strip()
            })

        return blocks

    def fix_issue(self, repo_url: str, issue: GitHubIssue) -> Optional:
        """
        Main entry point: fix a GitHub issue and return the PR URL.
        """
        task = parse_github_issue(issue)

        # Start sandbox and clone repo
        self.sandbox.start(repo_url)

        system_prompt = """You are an expert software engineer. Your task is to fix a bug or implement a feature.

        Rules:
        1. First explore the codebase to understand the structure
        2. Write minimal, correct code
        3. Run tests after every change
        4. If tests fail, read the error and fix it
        5. Never delete existing functionality
        6. Use the format ```language:filepath for code blocks

        Available commands:
        - cat <file> : Read a file
        - grep -r <pattern> : Search codebase
        - python -m pytest <path> : Run tests
        - git diff : See your changes
        """

        user_prompt = f"""Fix this issue in the repository at /workspace/repo:

        Title: {issue.title}

        Problem: {task.problem_statement}

        Expected behavior: {task.expected_behavior}

        Affected files: {', '.join(task.affected_files)}

        Start by reading the relevant files and understanding the codebase."""

        for iteration in range(self.max_iterations):
            print(f"Iteration {iteration + 1}/{self.max_iterations}")

            # Get LLM response
            response = self._call_llm(system_prompt, user_prompt)

            # Extract and apply code changes
            code_blocks = self._extract_code_blocks(response)

            for block in code_blocks:
                filepath = f"/workspace/repo/{block['filepath']}"
                self.sandbox._execute_command(
                    f"cat > {filepath} << 'EOF'\n{block['code']}\nEOF"
                )

            # Run tests
            passed, output = self.sandbox.run_tests()

            if passed:
                print("All tests passed!")
                # Create commit and PR
                pr_url = self._create_pull_request(repo_url, issue)
                self.sandbox.cleanup()
                return pr_url

            # Tests failed, feed output back to LLM
            user_prompt = f"""Tests failed with this output:

            {output[:2000]}  # Truncate to avoid token limits

            Fix the issues and run tests again."""

        # Max iterations reached without success
        print("Failed to fix issue within iteration limit.")
        self.sandbox.cleanup()
        return None

    def _create_pull_request(self, repo_url: str, issue: GitHubIssue) -> str:
        """
        Create a pull request with the agent's changes.
        Uses GitHub API directly.
        """
        import requests

        # Get the repo name from URL
        repo_path = repo_url.replace("https://github.com/", "").replace(".git", "")

        # Create branch
        branch_name = f"fix/issue-{issue.issue_number}"
        self.sandbox._execute_command(
            f"cd /workspace/repo && git checkout -b {branch_name} && "
            f"git add -A && git commit -m 'Fix {issue.title}' && "
            f"git push origin {branch_name}"
        )

        # Create PR via API
        headers = {
            "Authorization": f"token {os.environ['GITHUB_TOKEN']}",
            "Accept": "application/vnd.github.v3+json"
        }

        data = {
            "title": f"Fix: {issue.title}",
            "body": f"Automated fix for issue #{issue.issue_number}\n\n"
                    f"Closes #{issue.issue_number}",
            "head": branch_name,
            "base": "main"
        }

        response = requests.post(
            f"https://api.github.com/repos/{repo_path}/pulls",
            headers=headers,
            json=data
        )

        if response.status_code == 201:
            return response.json()["html_url"]
        else:
            raise RuntimeError(f"Failed to create PR: {response.text}")

The agent uses a low temperature (0.2) to keep code generation deterministic. Higher temperatures produce creative but buggy code. The conversation history keeps the last 5 turns to maintain context without exceeding token limits.

Pitfalls and Production Tips

After running this agent on dozens of repositories, here are the issues you'll hit in production:

Token limits on large files. The agent reads entire files into context. A 2000-line Python file can consume 4000+ tokens just for the content. Solution: implement a file chunker that reads only the relevant functions or classes. Use grep to find the line numbers of the bug, then read 50 lines above and below.

Test flakiness. The agent treats any non-zero exit code as a failure. If your test suite has flaky tests (network timeouts, race conditions), the agent will loop forever trying to fix non-existent bugs. Solution: run tests three times before reporting failure. If two out of three pass, consider it a success.

Git merge conflicts. The agent creates branches from main, but by the time it finishes, main may have moved. Solution: rebase the branch before pushing. The agent should run git pull --rebase origin main before creating the PR.

API costs. Each iteration costs tokens for the prompt and completion. A typical fix takes 3-5 iterations, costing $0.50-$1.00 in API calls. For complex issues, this can reach $5.00. Solution: set a budget limit and stop the agent if costs exceed it. Track token usage with response.usage.total_tokens.

Security boundaries. The sandbox has no network access, but the agent can still write malicious code to disk. If you're running this on a shared server, the agent could fill up disk space or create fork bombs. Solution: set Docker resource limits (mem_limit, cpu_quota) and disk quotas. Monitor the workspace directory size.

What's Next

The agent works for straightforward bug fixes and feature additions. The next step is adding support for multi-file refactoring where the agent needs to understand cross-cutting concerns. You can extend the agent to handle pull request reviews by feeding it the diff and asking it to check for common issues like missing error handling or security vulnerabilities.

For teams using this in CI/CD, integrate the agent with GitHub Actions. When a new issue is labeled "good first issue," trigger the agent automatically. If the agent's PR passes human review, merge it. This turns your issue tracker into a self-service bug fixing pipeline.

The code in this tutorial is a starting point. The real value comes from tuning the prompts and iteration logic to your specific codebase. Start with one repository, run the agent on 10 issues, and analyze where it fails. Those failure patterns will tell you exactly what to fix next.


References

1. Wikipedia - OpenAI. Wikipedia. [Source]
2. Wikipedia - LangChain. Wikipedia. [Source]
3. Wikipedia - Rag. Wikipedia. [Source]
4. GitHub - openai/openai-python. Github. [Source]
5. GitHub - langchain-ai/langchain. Github. [Source]
6. GitHub - Shubhamsaboo/awesome-llm-apps. Github. [Source]
7. GitHub - Significant-Gravitas/AutoGPT. Github. [Source]
8. OpenAI Pricing. Pricing. [Source]
9. LangChain Pricing. Pricing. [Source]
tutorialai
Share this article:

Was this article helpful?

Let us know to improve our AI generation.

Related Articles