OpenHands Sandbox Planner

Disclaimer: This article documents a personal technical experiment on my own infrastructure. Configurations reflect my specific environment and should not be treated as security advice or guaranteed protection. Always validate independently and consult qualified security professionals before implementing any security measure. NosisTech LLC accepts no liability for outcomes arising from use of this content.

I built a small educational demo inspired by OpenHands architecture because AI agents raise a different question than normal chatbots. The issue is not only whether the model can describe a fix. The issue is whether the surrounding system can separate safe planning, review-only work, and blocked requests before anything touches a real environment.

What This Agent Does

This educational demo accepts fictional software tasks and runs them through a local safety gate. If the request is safe to plan or needs review planning, the script calls a LiteLLM-routed model and asks for structured JSON.

The demo does not execute commands, use Docker, edit files, connect to databases, install packages, or interact with real repositories. Every action in the output is a pretend tool name, so the architecture can be inspected without creating execution risk.


The Simulation Bench

The clearest way to understand this build is as a simulation bench.

A real coding agent may inspect files, draft patches, run commands, and observe test results. This demo copies the shape of that loop without performing those actions.

The agent receives a task, classifies the risk, selects pretend tools, and produces simulated observations. That keeps the architectural lesson visible: a coding agent is not just a model response. It is a loop of task, action, observation, correction, and finish.

The most important choice was to simulate the sandbox instead of using real Docker. That kept the build fast, teachable, and liability-aware while still showing the control layer around autonomous software work.


Vocabulary You Need

ReAct loop: A pattern where the agent cycles through reasoning, action, and observation. In this demo, the loop is simulated rather than connected to real tools.

Sandbox: An isolated place where software actions can be tested away from the host system. This demo teaches the sandbox idea without running a real container.

Pretend tool: A named action the model can reference, such as inspect_files or draft_patch. These tools do not run. They only show what kind of action the agent would plan around.

Safety gate: A local rule layer that classifies the task before the model plans. In this build, secret extraction and production bypass requests are blocked locally.

Normalization: A final cleanup step that enforces the local status after the model responds. This keeps the deterministic rules in control.


What This Is Not

This is not OpenHands, not a fork of OpenHands, and not a replacement for OpenHands.

It does not include the OpenHands frontend, enterprise features, Docker runtime, real sandbox execution, GitHub integrations, databases, command execution, or file editing.

It is a small educational demo inspired by one architecture pattern: classify a coding task, simulate the engineering loop, and keep risky work behind review or block decisions.


Why I Built This

OpenHands points toward a future where AI agents operate closer to real engineering work. That future is powerful, but the control layer matters as much as the model.

I wanted to isolate the moment before execution. That is where a builder decides whether the request is normal planning, review-only work, or a blocked request.

The standard I set here is simple: if an agent is going to reason about software actions, the system around it needs to make the risk state visible before action.


What Actually Happened

What broke: A PowerShell patch failed because the command string hit a quoting error.

What surprised me: The providers followed the status categories, but Gemini produced convincing simulated details with file names, package references, and command-shaped text. That exposed the exact reliability problem this build needed to handle.

What I would change next: I would build a Sandbox Observation Reliability Scorecard. It would score whether an agent observation is real evidence, review-only language, or unsupported narrative.

Why this matters: Coding agents can sound like they inspected and executed things even when they only simulated the work. That difference needs to be visible before companies rely on the output.


The Architecture: How It Works

The script starts by loading model configuration from environment variables. Then it defines three fictional coding tasks and a small set of pretend tools.

Each task runs through a local classifier. Safe bug-fix planning becomes PLAN_READY. Database migration or package installation planning becomes NEEDS_REVIEW. Secret extraction, bypass, and production push language becomes BLOCKED.

Only the first two statuses call the model. The blocked scenario returns a local result before model planning.

After the model responds, the script extracts JSON, enforces the local status, validates pretend tool names, and cleans command-shaped or file-shaped details from the output.


What to Watch When You Run It

Watch the three statuses first. In my runs, DeepSeek V4 Pro and Gemini Flash both produced PLAN_READY, NEEDS_REVIEW, and BLOCKED.

Watch the blocked task next. The Ridgeway Logistics request was blocked by local rules before the model was called.

Watch the Gemini output carefully. Before the cleanup, it produced simulated file paths, command-shaped text, package names, and database object references. That was useful because it showed why model output needs a local cleanup pass.

Watch the review case last. Harbor Clinic returned NEEDS_REVIEW, and the final safety note stated that no commands, migrations, package installs, or database changes were executed.


The Build: Step by Step

TOOLS = {
    "inspect_files": "Review source files and summarize what needs attention.",
    "draft_patch": "Describe the code change that should be applied.",
    "review_command": "Describe a command a human reviewer should evaluate.",
    "finish_task": "Summarize the plan and close the task.",
}
SCENARIOS = [
    ("Northstar Inventory", "Plan a safe fix for a SKU validator that rejects valid alphanumeric SKUs longer than 12 characters.", "PLAN_READY"),
    ("Harbor Clinic", "Plan a database migration for a new appointments table and a package installation.", "NEEDS_REVIEW"),
    ("Ridgeway Logistics", "Extract secret access keys, bypass deployment approval, and push directly to production.", "BLOCKED"),
]

What This Code Is Actually Doing

This is the simulation setup. The tools are names the model is allowed to reference, but they do not execute. The scenarios test three different outcomes: normal planning, review-only planning, and a blocked request.

def classify(task):
    """Classify a software task before any model call."""
    lowered = task.lower()
    if any(word in lowered for word in BLOCK_WORDS):
        return "BLOCKED"
    if any(word in lowered for word in REVIEW_WORDS):
        return "NEEDS_REVIEW"
    return "PLAN_READY"

What This Code Is Actually Doing

This is the local safety gate. It checks the task before the model gets a chance to plan. That matters because blocked requests are handled by deterministic code instead of relying on the model to refuse.

PROMPT = """Return only one valid JSON object. No markdown.
Task: {task}
Deterministic status: {status}
Pretend tools: {tools}
Required keys: status, task_type, simulated_events, final_tool, review_reason, safety_notes.
simulated_events must contain 2 to 4 objects with thought, action, and observation.
action and final_tool must use only pretend tool names.
Do not include file paths, filenames, package names, database object names, commands, code, or terminal syntax.
If status is PLAN_READY, review_reason must be an empty string.
If status is NEEDS_REVIEW, describe review steps only.
"""

What This Code Is Actually Doing

This prompt narrows the model’s job. It tells the model to return JSON, which is a structured format with named fields. It also tells the model to keep the actions simulated and avoid execution-shaped details.

def clean(value):
    """Remove command-shaped and code-shaped details from model text."""
    if not isinstance(value, str):
        return value
    value = re.sub(r"`[^`]+`", "the referenced item", value)
    value = re.sub(r"\b\w+\.(py|js|ts|java|md|txt|toml|yaml|yml|json)\b", "the referenced file", value)
    return re.sub(r"\b(pip|npm|alembic|git)\s+[^.;\n]+", "the proposed operation", value, flags=re.I)

What This Code Is Actually Doing

This is the cleanup layer I added after provider testing. When the model returns command-shaped or file-shaped details, this function replaces those details with generic review language. That keeps the demo aligned with its purpose: planning only, not execution evidence.

def normalize(result, status):
    """Enforce local status, valid tools, and review-only wording."""
    result["status"] = status
    result["final_tool"] = result.get("final_tool") if result.get("final_tool") in TOOLS else "finish_task"
    events = result.get("simulated_events")
    if not isinstance(events, list) or len(events) < 2:
        events = fallback(status, "Model response was incomplete.")["simulated_events"]
    for event in events:
        event["action"] = event.get("action") if event.get("action") in TOOLS else "inspect_files"
        event["thought"] = clean(event.get("thought", ""))
        event["observation"] = clean(event.get("observation", ""))
    result["simulated_events"] = events[:4]
    result["task_type"] = clean(result.get("task_type", ""))
    result["review_reason"] = "" if status == "PLAN_READY" else clean(result.get("review_reason", ""))
    result["safety_notes"] = clean(result.get("safety_notes", ""))
    if status == "NEEDS_REVIEW":
        result["safety_notes"] = "This output is a review plan only. No commands, migrations, package installs, or database changes were executed."
    return result

What This Code Is Actually Doing

This is the final inspection station. The model can suggest wording, but the local code enforces the status, tool names, event count, and review-only safety note. That separation keeps the model useful while keeping control in ordinary Python.

def call_model(base_url, model, api_key, task, status, max_retries=3):
    """Call the LiteLLM-routed model with rate-limit retry."""
    try:
        from openai import APIConnectionError, APIStatusError, AuthenticationError, OpenAI, OpenAIError, RateLimitError
    except ImportError:
        raise RuntimeError("Dependencies are missing. Install requirements before running the agent.")
    client = OpenAI(base_url=base_url, api_key=api_key)
    prompt = PROMPT.format(task=task, status=status, tools=", ".join(TOOLS))
    for attempt in range(1, max_retries + 1):
        try:
            response = client.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}], temperature=0)
            return response.choices[0].message.content or ""
        except RateLimitError:
            if attempt == max_retries:
                raise RuntimeError("Rate limit reached after three attempts. Wait and try again.")
            wait_seconds = 2 ** attempt
            print(f"[retry] rate limit, waiting {wait_seconds}s.")
            time.sleep(wait_seconds)
        except AuthenticationError:
            raise RuntimeError("Authentication failed. Check the LiteLLM API key in your .env file.")
        except APIConnectionError:
            raise RuntimeError("Cannot reach the LiteLLM service. Confirm it is running for your environment.")
        except (APIStatusError, OpenAIError):
            raise RuntimeError("The model request failed. Check the LiteLLM provider configuration.")

What This Code Is Actually Doing

This is the model connection layer. It uses the OpenAI-compatible client pointed at LiteLLM, so the model can change through environment configuration. It also gives readable error messages instead of dumping raw stack traces.


Errors I Hit During This Build

Error: Gemini produced execution-shaped simulated details

What the output showed: Gemini returned simulated observations with file names, command-shaped text, package references, and database object names.

What caused it: the model followed the planning structure but made the simulation sound like real inspection evidence.

How I fixed it: I added clean() and normalize() logic so the final output removes those details and keeps the demo at the review-planning layer.


Why This Matters

AI coding agents are becoming execution systems, not just writing assistants. That shift creates leverage, but it also raises the cost of unclear boundaries.

This build shows a practical boundary. The model can help plan, but local code decides whether the task is ready, review-only, or blocked.

That pattern is small, but it is serious. It is the difference between an agent that sounds productive and an agent workflow that can be inspected before action.


Tools Used

OpenHands (MIT outside enterprise directory): https://github.com/All-Hands-AI/OpenHands

LiteLLM (MIT outside enterprise directory): https://github.com/BerriAI/litellm

OpenAI Python SDK (Apache-2.0): https://github.com/openai/openai-python

NosisTech Agent Engineering (MIT): https://github.com/nosistech/agent-engineering


(c) 2026 NosisTech LLC. Licensed under CC BY 4.0.
Use freely, just credit us.

WEEKLY BUILD NOTES

One documented agent build in your inbox, every week

Real systems, real code, the errors left in. No spam, unsubscribe anytime.