Agent harnesses come with goals, subagents, planners, and much more. Feels quite complex, doesn’t it?
Fundamentally, however, they don’t have to be. Pi, my daily driver, builds on this idea: minimalism, also in agent harnesses.
I like stripping things down to their core - microai project, is one example. When I recently needed to build an agent (more on that in another post), my first instinct was to train a model. A harness around an existing one proved a better starting point.
What Is a Harness?
A harness is the software around an LLM that lets it carry out a task: instructions, tools, conversation, and the machinery connecting them. Here’s a nice article with more detail.
Take a coding agent. You ask it to fix a bug. It reads the source, runs a test, sees the failure, edits the code, and tries again. Each result becomes context for the next decision.
There are surprisingly few moving pieces:
- An LLM: decides what to do, given instructions and context.
- Tools: execute the doing part; named operations with defined arguments and results.
- A loop: enables repetition; tool results feed back into the model to support the next decision.
- Events: communicate progress and outcomes; updates let the rest of the application follow along.
The Architecture
Those pieces make the core. To turn it into a coding agent, we add an orchestrator that supplies concrete tools, prepares context, and manages the task. This is the business logic.
Around it sit the interfaces: APIs, TUIs, web UIs. Different ways to use the same orchestrator, sharing the same logic.
The Core
The core understands messages and tool calls. A Provider streams responses; a ToolRuntime describes and executes tools. Both are interfaces. The loop only knows how to invoke them.
Below is a simplified core; imports are omitted for readability.
loop.py - the model/tool exchange
class AgentLoop:
def __init__(self, provider: Provider) -> None:
self.provider = provider
async def run(self, context: AgentLoopContext, tools: ToolRuntime) -> AsyncIterator[AgentEvent]:
for _ in range(context.max_steps):
assistant = None
try:
async for event in self.provider.stream(context.provider_messages, tools.definitions()):
yield event
if isinstance(event, CompletionEnd):
assistant = event.message
except Exception as error:
yield AgentError(str(error))
return
if assistant is None:
yield AgentError("Provider ended without a complete message.")
return
context.provider_messages.append(assistant)
if not assistant.tool_calls:
yield AgentCompleted(assistant)
return
for call in assistant.tool_calls:
yield ToolCallStart(call)
try:
result = await tools.execute(call)
except Exception as error:
result = ToolResult(str(error), is_error=True)
yield ToolCallEnd(call, result)
if result.requires_input:
yield InputRequired()
return
context.provider_messages.append(tool_message(call, result))
yield MaxStepsReached()
def tool_message(call: ToolCall, result: ToolResult) -> ProviderMessage:
return ProviderMessage(
role="tool",
content=f"{"error" if result.is_error else "success"}: {result.content}",
tool_call_id=call.id,
image_data_urls=(result.image_data_url,) if result.image_data_url else (),
)
The return trip matters. Results go back into the conversation, paired with their call IDs. Errors become feedback, too. Results can include images or request user input.
A response without tool calls finishes the loop. A step budget bounds model turns; tool implementations still need their own timeouts.
schemas.py - messages and interfaces
@dataclass
class ToolDefinition:
name: str
description: str
parameters: dict[str, object]
@dataclass
class ToolCall:
id: str
name: str
arguments: dict[str, object] = field(default_factory=dict)
@dataclass
class ToolResult:
content: str
is_error: bool = False
requires_input: bool = False
image_data_url: str | None = None
class ToolRuntime(Protocol):
def definitions(self) -> Sequence[ToolDefinition]: ...
async def execute(self, call: ToolCall) -> ToolResult: ...
@dataclass
class ProviderMessage:
role: Literal["system", "user", "assistant", "tool"]
content: str = ""
tool_calls: list[ToolCall] = field(default_factory=list)
tool_call_id: str | None = None
image_data_urls: tuple[str, ...] = ()
@dataclass
class AgentLoopContext:
provider_messages: list[ProviderMessage] = field(default_factory=list)
max_steps: int = 32
class Provider(Protocol):
def stream(self, messages: Sequence[ProviderMessage], tools: Sequence[ToolDefinition]) -> AsyncIterator[ProviderEvent]: ...
The provider emits start/delta/end events for completions and, when available, reasoning. The loop adds tool and lifecycle events.
events.py - following the work
@dataclass
class CompletionStart:
pass
@dataclass
class CompletionDelta:
text: str
@dataclass
class CompletionEnd:
message: ProviderMessage
duration_ms: int
@dataclass
class ReasoningStart:
pass
@dataclass
class ReasoningDelta:
text: str
@dataclass
class ReasoningEnd:
reasoning: str
duration_ms: int
ProviderEvent = (
CompletionStart
| CompletionDelta
| CompletionEnd
| ReasoningStart
| ReasoningDelta
| ReasoningEnd
)
@dataclass
class ToolCallStart:
call: ToolCall
@dataclass
class ToolCallEnd:
call: ToolCall
result: ToolResult
@dataclass
class AgentCompleted:
message: ProviderMessage
@dataclass
class InputRequired:
pass
@dataclass
class MaxStepsReached:
pass
@dataclass
class AgentError:
message: str
AgentEvent = (
ProviderEvent
| ToolCallStart
| ToolCallEnd
| AgentCompleted
| InputRequired
| MaxStepsReached
| AgentError
)
The Orchestrator
Now we give the loop a job. A coding agent, for example, needs a workspace and concrete tools such as file read or edit. The orchestrator supplies these implementations and prepares the conversation around the task.
It also owns the task’s lifecycle: retaining conversation history, handling user interactions, and checking the result.
The application boundary - an interface, too
@dataclass
class TaskRequest:
instruction: str
workspace_root: Path
@dataclass
class TaskEvent:
kind: Literal["working", "input_required", "completed", "failed"]
message: str = ""
class Orchestrator(Protocol):
def run(self, request: TaskRequest) -> AsyncIterator[TaskEvent]: ...
The orchestrator owns the business logic and exposes it through operations and events. An Agent Client Protocol (ACP) adapter can offer these to ACP-compatible clients.
The Interfaces
Put a TUI on it, or expose it over HTTP for a web UI. Both send requests to the same orchestrator and turn its events into feedback. A task update might become an API message or a terminal redraw.
Dependencies point inward, like an onion: interfaces -> orchestrator -> core.
This is the software-design part I enjoy: giving each piece one job and a boundary that makes sense. Change a provider, add a terminal interface, test the loop with a fake tool - the pieces fit.
A small core doesn’t make the surrounding work disappear. It supports everything we build around it.