DEV Community

Chen Yuan
Chen Yuan

Posted on Originally published at dispatch-blog.hashnode.dev

What Does an Agent Harness Actually Do? Building a Minimal One in Python

What Is a Harness, Really?

You have used a coding agent. You type a prompt, it edits files, runs tests, maybe even opens a browser. But when you look inside the request, you are not sending your prompt directly to the model. Between you and the LLM sits a piece of infrastructure that does almost all of the work. That is the harness.

An agent harness is the runtime scaffolding that turns a language model into an agent that can perform work. It drives model and tool calls, manages conversation state and context, applies approval policies, and keeps the agent progressing through multi-step tasks. In simpler terms: the harness is everything around the model. The model thinks; the harness acts.

Think of the harness as the operating system for your agent. It holds the system prompt, which sets the agent’s role, behavior, and decision-making style. It maintains the message history. It decides when to stop. It turns the model’s request for a tool into an actual function call in your runtime. Without a harness, you have a chat endpoint. With a harness, you have something that can actually do things.

The modern harness was not designed top-down from abstractions. It was born bottom-up out of coding agents solving real problems. Claude Code runs on one. DeepSeek Harness treats every capability—tools, skills, even the agent loop itself—as a swappable plugin, and it explicitly defines Agent = Model + Harness. The harness is the layer that makes the model useful in the real world.

The Four Jobs of a Harness

Every harness does four things. Nothing more, nothing less.

First, it holds the system prompt. That prompt is the agent’s job description. It tells the model who it is, what tools it has, and how it should behave. The user never sees it, but it shapes every response. Here is what one looks like:

system_prompt = """You are a coding assistant with access to these tools:
- read_file(path: str) -> str
- write_file(path: str, content: str) -> None
- run_shell(command: str) -> str

You have one job: help the user complete their task. When you need information,
call a tool. Do not guess. Do not make up file contents. When you have enough
information to answer, provide the final answer and stop."""
Enter fullscreen mode Exit fullscreen mode

Second, it manages the message history. The harness keeps every user message, assistant response, and tool result in a single list. That list goes back to the model on every request. The harness does not just append; it trims, compresses, and summarizes when the context window gets tight.

Third, it translates tool calls into real execution. The model outputs a tool_calls array. The harness parses that array, invokes the corresponding functions in your environment, collects the results, and sends them back as role: "tool" messages. That round trip is the core of the harness.

Fourth, it enforces permissions. The model can ask to run any shell command or delete any file. The harness decides whether to allow it, ask for approval, or block it entirely. Claude Code runs every tool call through a permission check before execution. Your harness should too.

def check_permission(tool_name: str, arguments: dict) -> bool:
    if tool_name == "run_shell" and "rm -rf" in arguments.get("command", ""):
        return False  # block dangerous commands
    if tool_name == "write_file" and not arguments.get("path", "").startswith("./"):
        return False  # only allow writing to current directory
    return True
Enter fullscreen mode Exit fullscreen mode

The Agentic Loop, Line by Line

The agentic loop is the reason–act–observe cycle that drives every step of an AI agent. The model generates a response, optionally invokes tools, observes the results, and loops until the task is done. That is it.

Here is a complete, runnable single-file Python agent loop using the OpenAI API. It handles tools, dispatch, and the role="tool" message round trip.

import json
from openai import OpenAI

client = OpenAI()

def get_weather(city: str) -> str:
    return f"The weather in {city} is sunny, 72°F."

def get_time(timezone: str) -> str:
    return f"The time in {timezone} is 3:00 PM."

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather for a city",
            "parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_time",
            "description": "Get current time for a timezone",
            "parameters": {"type": "object", "properties": {"timezone": {"type": "string"}}}
        }
    }
]

tool_map = {"get_weather": get_weather, "get_time": get_time}

messages = [{"role": "system", "content": "You are a helpful assistant with tools."}]
user_input = input("You: ")
messages.append({"role": "user", "content": user_input})

while True:
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        tools=tools,
        tool_choice="auto"
    )
    assistant_message = response.choices[0].message
    messages.append(assistant_message.model_dump())

    if assistant_message.tool_calls:
        for tool_call in assistant_message.tool_calls:
            tool_name = tool_call.function.name
            arguments = json.loads(tool_call.function.arguments)
            result = tool_map[tool_name](**arguments)
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result
            })
    else:
        print("Assistant:", assistant_message.content)
        break
Enter fullscreen mode Exit fullscreen mode

That loop does exactly what every harness does. It sends the conversation to the model. It checks whether the model requested tools. If yes, it executes them and appends the results as role: "tool" messages. Then it loops. If no, it prints the answer and stops.

The tool_calls field on the assistant message contains the model's requests. Each call has an id, a function name, and arguments as a JSON string. The harness must send back a message with role: "tool", the same tool_call_id, and the result as content. That is the contract.

# When the model calls get_weather(city="Boston"), the assistant message looks like:
{
    "role": "assistant",
    "tool_calls": [{
        "id": "call_abc123",
        "type": "function",
        "function": {"name": "get_weather", "arguments": '{"city": "Boston"}'}
    }]
}
# Your harness runs get_weather("Boston") and sends back:
{
    "role": "tool",
    "tool_call_id": "call_abc123",
    "content": "The weather in Boston is sunny, 72°F."
}
Enter fullscreen mode Exit fullscreen mode

Tools: Teaching the Model to Ask Instead of Guess

The model does not call functions. It outputs a structured request to call a function. The harness executes the function and returns the result. That distinction matters because it means the harness, not the model, controls what actually runs.

You define tools as a list of JSON schemas. Each tool has a name, a description, and a parameters schema. The description is critical. The model uses it to decide which tool to call and with what arguments. Write descriptions that are concrete and specific.

tools = [{
    "type": "function",
    "function": {
        "name": "get_user_by_email",
        "description": "Look up a user by their email address. Returns user ID, name, and role.",
        "parameters": {
            "type": "object",
            "properties": {
                "email": {"type": "string", "description": "The user's email address"}
            },
            "required": ["email"]
        }
    }
}]
Enter fullscreen mode Exit fullscreen mode

The harness can also force a specific tool using tool_choice. Set it to {"type": "function", "function": {"name": "get_weather"}} to skip the model's reasoning and go straight to the tool. That is useful for routing or for tools that the model should always call first.

Real harnesses expose many tools. Claude Code keeps its toolbox deliberately small—eighteen tools in the snapshot captured in late 2025, every one sitting behind a permission gate. OpenHarness ships with forty-three tools covering file I/O, shell, and more. The harness validates the arguments against the schema before executing the function. If the model sends malformed JSON or missing required fields, the harness rejects the call and returns an error message to the model.

def validate_tool_call(tool_call, tool_schema):
    required = tool_schema["function"]["parameters"].get("required", [])
    args = json.loads(tool_call.function.arguments)
    for field in required:
        if field not in args:
            return False, f"Missing required field: {field}"
    return True, None
Enter fullscreen mode Exit fullscreen mode

Context: The Window Is a Budget

Every request to the model has a context window. For GPT-4o, that is 128,000 tokens. That sounds like a lot until you start appending file contents, search results, and tool outputs. The harness must manage that budget.

The harness does not just append every message forever. It tracks token counts. When the conversation gets too long, it has to decide what to drop. The simplest strategy is a sliding window: keep the system prompt, the last N user-assistant exchanges, and the most recent tool results. Drop everything else.

def trim_history(messages, max_tokens=100000):
    # Keep system prompt (index 0). Then keep the most recent messages
    # until we hit the token limit. This is a simplified version.
    system_msg = messages[0]
    recent = messages[1:]
    # In practice, you would use tiktoken to count tokens.
    # For now, just keep the last 20 messages.
    trimmed = [system_msg] + recent[-20:]
    return trimmed
Enter fullscreen mode Exit fullscreen mode

More sophisticated harnesses summarize old turns. They send a compressed version of the history back to the model. Some use a separate summarization model. Others just truncate. The choice depends on how much context your task requires.

The harness also manages the context across tool calls. When the model calls a tool, the harness appends the result and immediately sends the updated message list back to the model. That means the model sees the tool output in the next request, not in the same request. The harness is the intermediary that makes that round trip possible.

# After executing a tool, the harness appends the result and loops.
# The next model request includes the tool result in the message history.
messages.append({
    "role": "tool",
    "tool_call_id": tool_call.id,
    "content": result
})
# The loop continues. The model now sees the tool output.
Enter fullscreen mode Exit fullscreen mode

Permissions: The Harness Holds the Leash

The model can ask to do anything. The harness decides what it is allowed to do. That is the permission system.

Every tool call flows through a permission check before execution. The check can be automatic (allow or deny based on rules), interactive (ask the user for approval), or hybrid (allow some operations, ask for others). Claude Code gates every tool call behind its permission modes. DeepSeek Harness routes approvals through a swappable plugin, so you can replace the whole approval flow the way you swap any other component.

The simplest permission system is a whitelist. Define which tools are allowed and which arguments are acceptable. Block everything else.

ALLOWED_TOOLS = {"read_file", "write_file", "search_code"}
BLOCKED_PATTERNS = ["rm -rf", "sudo", "chmod 777"]

def permission_check(tool_name: str, arguments: dict) -> tuple[bool, str]:
    if tool_name not in ALLOWED_TOOLS:
        return False, f"Tool {tool_name} is not allowed"
    if tool_name == "run_shell":
        command = arguments.get("command", "")
        for pattern in BLOCKED_PATTERNS:
            if pattern in command:
                return False, f"Command contains blocked pattern: {pattern}"
    return True, ""
Enter fullscreen mode Exit fullscreen mode

The harness can also implement a confirmation flow. When the model requests a dangerous operation, the harness pauses, prompts the user, and only continues if the user approves. That is how coding agents avoid accidentally deleting your entire project.

def execute_with_approval(tool_name, arguments):
    if tool_name == "delete_file":
        print(f"Model wants to delete {arguments['path']}. Approve? (y/n)")
        if input().lower() != "y":
            return "Operation cancelled by user"
    return tool_map[tool_name](**arguments)
Enter fullscreen mode Exit fullscreen mode

What We Skipped (and Why It Matters)

The minimal harness above works, but it is not production-ready. Real harnesses handle a lot more.

Streaming. Real harnesses start executing tools before the model finishes generating. That cuts latency. The model streams tool calls as it generates them; the harness kicks off the tool execution in parallel. Our loop waits for the full response before dispatching anything.

Parallel tool calls. The model can request multiple tools in one response. Our loop executes them sequentially. A real harness runs them concurrently and collects all results before sending the next request.

Retries and error handling. Tools fail. The harness catches exceptions, formats them as error messages, and sends them back to the model. The model can then retry with corrected arguments or try a different approach.

try:
    result = tool_map[tool_name](**arguments)
except Exception as e:
    result = f"Error: {str(e)}. Please try again with different arguments."
Enter fullscreen mode Exit fullscreen mode

State persistence. The harness holds the conversation state in memory. If the process crashes, the state is lost. Real harnesses persist the message history, tool results, and progress to disk or a database.

Cost control. Every loop iteration costs money. The harness tracks token usage and can enforce a budget. If the agent exceeds the budget, the harness stops the loop and returns a partial result.

total_tokens = 0
MAX_TOKENS = 100000
while total_tokens < MAX_TOKENS:
    response = client.chat.completions.create(...)
    total_tokens += response.usage.total_tokens
    # process response...
Enter fullscreen mode Exit fullscreen mode

Tool registration. Our loop uses a hardcoded tool_map. Real harnesses have a registry where tools are dynamically added, removed, and discovered. Plugins register their own tools.

Build One Yourself

You now know what a harness does. The next step is to build your own.

Start with the loop from Section 3. Add a system prompt that defines your agent's personality and available tools. Add a few tools that matter to you: read a file, search the web, run a shell command. Add a permission check that blocks dangerous operations. Run it with a real task and watch the loop go.

Then make it better. Add streaming. Add parallel tool calls. Add a token budget. Add persistence. Add a summarizer for long histories. Each addition teaches you something about how real harnesses work.

The harness is not magic. It is a while loop, a message list, and a dispatch table. The model does the thinking. The harness does the work. Build one, and you will never look at a coding agent the same way again.


Originally published on Dispatch.

Top comments (0)