Agent Harness: Everything Around the Model, and How to Configure It
The model is maybe a third of an agent. The rest is the harness: instructions, tools, permissions, hooks, state files, sandbox and traces. This post explains what a harness is, walks through a real configuration with Claude Code and the Agent SDK, and shows the pattern that keeps agents working across sessions.

Bản tiếng Việt: Agent Harness: mọi thứ bao quanh model, và cách cấu hình. Part 2 of the series. Part 1 was The Agent Loop; part 3 is Context Engineering.
Two years ago, when someone on my team said an agent "wasn't working", we opened the prompt. Today we open the harness first, and nine times out of ten that is where the problem is: a tool that returns too much, a permission that was never granted, a progress file nobody told the agent to read, a hook that silently blocked the one command it needed. The model has become the most reliable component in the system. The scaffolding we put around it has not.
"Harness" is the word the industry settled on for that scaffolding. Anthropic used it in November 2025 for the structure that runs a model as an agent across many sessions. OpenAI used it in February 2026 for a whole discipline, harness engineering, after a small team shipped roughly a million lines of code in five months without hand-writing any of it. Their motto, "Humans steer. Agents execute.", is a good one-line definition of what a harness is for.
This post is the practical version: what the harness consists of, what each part looks like as a real file or a real line of code, and the order in which I build them.
What is in a harness
Take the 40-line loop from part 1 and ask what else has to exist for that loop to be safe to run unattended against a real codebase or a real customer. You get six layers. I draw them as rings around the model, because that is how failures propagate: inside-out.
The loop itself: termination, budgets, tool dispatch, retries. Covered in part 1.
The instruction layer: the files that tell the model what this project is, how to work in it, and where to look. CLAUDE.md, AGENTS.md, skills. The system prompt is a part of this, but the smaller part.
The capability layer: the tools the model can call, their schemas, and the servers that provide them (local functions, MCP servers, CLIs). What the agent can do.
The policy layer: permissions, hooks, sandboxing. What the agent may do, who approves the rest, and what happens when it tries anyway.
The state layer: everything that survives a session. Git, a progress log, a feature list, checkpoints. Without it, every session starts from zero and long tasks fall apart.
The observation layer: traces, metrics, cost accounting, and the evals that consume them.
Frameworks give you the loop and part of the capability layer. The rest is yours, and it is where the time goes.
A concrete harness: Claude Code as the reference
I will use Claude Code as the worked example because its harness is entirely visible as files in your repository, which makes it easy to reason about, and because the same files are read by the Agent SDK when you go programmatic. If you use Codex, Cursor or another tool, the file names change and the ideas do not.
Instructions: a map, not a manual
CLAUDE.md at the repository root is loaded into every session. The temptation is to make it exhaustive. Do not. OpenAI's team found that one large instruction file "crowded out context, went stale, and was hard to verify", and settled on about a hundred lines that act as a table of contents. The same thing happened to us: our first CLAUDE.md was 600 lines of rules, and the agent followed roughly the first 150 of them.
A good one looks like this:
# Contract pipeline
Python 3.12, FastAPI, Postgres. Monorepo; each service under `services/<name>`.
## Commands
- Install: `uv sync`
- Test one service: `uv run pytest services/<name> -x -q`
- Lint + types: `uv run ruff check . && uv run mypy services`
- Never run `alembic upgrade` against anything but the local DB.
## How we work
- One feature per PR. Open a draft PR early with the plan in the description.
- Every bug fix ships with a regression test in the same PR.
- Secrets come from `.env.local` (never committed). Do not read it; use the settings module.
## Where to look
- Architecture and data flow: `docs/architecture.md`
- Extraction rules for contract fields: `docs/extraction-rules.md`
- Known flaky tests and why: `docs/flaky-tests.md`
## Before you finish
Run lint, types and the tests for every service you touched. Summarise what changed and what you did not verify.
Everything the model needs on every turn is here. Everything it needs sometimes lives in docs/ and the file points to it. That is progressive disclosure, and it is the single most effective instruction-layer technique I know.
Skills: instructions loaded on demand
For knowledge that only applies to a kind of task, a skill is the right container. A skill is a folder with a SKILL.md; only its description is in context by default, and the body is loaded when the task matches. It is the open Agent Skills format, so the same folder works in more than one tool.
---
name: new-extractor
description: Add a new contract-field extractor. Use when asked to extract a new field, date or party type from documents.
allowed-tools: Read, Grep, Edit, Bash(uv run pytest *)
---
1. Read `docs/extraction-rules.md` and the existing extractor closest to the new field.
2. Create `services/extract/fields/<field>.py` following `fields/effective_date.py`.
3. Add fixtures under `tests/fixtures/<field>/` with at least one scanned and one native PDF.
4. Run `uv run pytest services/extract -k <field>` until green.
5. Append the field to the table in `docs/extraction-rules.md`.
That is a runbook a senior engineer would write for a junior. It costs nothing until the task matches, and it removes a whole class of "the agent did it a different way every time" problems.
Permissions: what runs without asking
.claude/settings.json is committed and shared with the team. Its permissions block decides what the agent may do silently, what it must ask about, and what it may never do. Rules name a tool and a pattern.
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Read",
"Grep",
"Glob",
"Bash(uv run pytest *)",
"Bash(uv run ruff *)",
"Bash(uv run mypy *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)"
],
"ask": [
"Bash(git push *)",
"Bash(docker *)",
"WebFetch"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(rm -rf *)",
"Bash(alembic upgrade *)",
"Bash(git push --force *)"
]
}
}
Three habits here. Allow by pattern, not by tool: Bash(uv run pytest *) rather than Bash. Put anything that touches money, production or the network in ask. And write deny rules for the things that keep you up at night, even if you believe the model would never do them; deny rules are the only ones that apply before a teammate has trusted the folder.
Hooks: code that runs around every tool call
Permissions are static. Hooks are code. A hook runs at a lifecycle event (PreToolUse, PostToolUse, Stop, SessionStart and others), receives the event as JSON on stdin, and can block, allow or annotate. This is where you put policy that needs logic, and where you attach your own observability.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 .claude/hooks/guard_bash.py" }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "python3 .claude/hooks/lint_changed.py" }]
}
]
}
}
The second hook reads the edited file's path from the event JSON (tool_input.file_path) and runs ruff --fix on it, so formatting never becomes a review comment. The first one is the guard below. Exit code 2 blocks the call and sends stderr back to the model as the reason, which is exactly the "tool results that teach" idea from part 1, applied before the tool runs.
#!/usr/bin/env python3
# .claude/hooks/guard_bash.py — block destructive shell commands, explain why
import json
import re
import sys
event = json.load(sys.stdin)
command = event.get("tool_input", {}).get("command", "")
RULES = [
(r"\bgit\s+push\b.*--force", "force-push is not allowed; open a PR instead"),
(r"\bgit\s+reset\s+--hard", "hard reset discards work; use `git stash` or a new branch"),
(r"\brm\s+-rf\s+(/|~|\.)", "refusing to delete the tree root; delete specific paths"),
(r"\bcurl\b.*\|\s*(ba)?sh\b", "piping remote scripts into a shell is not allowed"),
(r"DROP\s+(TABLE|DATABASE)", "schema changes go through a migration file"),
]
for pattern, reason in RULES:
if re.search(pattern, command, re.IGNORECASE):
print(f"Blocked: {reason}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
I keep a second PreToolUse hook that appends every tool call to a JSONL trace with the session id, so the observation layer comes for free. On Stop, a third hook runs the test suite and, if it fails, returns a message asking the agent to continue. That last one changed our completion rate more than any prompt change ever did.
Capabilities: MCP servers
Tools that are not functions in your process come in over MCP. .mcp.json in the repository declares them for the whole team:
{
"mcpServers": {
"postgres-readonly": {
"command": "uvx",
"args": ["mcp-server-postgres", "--read-only"],
"env": { "DATABASE_URL": "${PGRO_URL}" }
},
"browser": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless"]
}
}
}
Note the --read-only. Capability and policy are different layers, but the cheapest policy is a capability that cannot do the dangerous thing. A read-only database tool needs no deny rule.
The same harness, programmatically
When the agent runs as part of a service rather than in a terminal, you build the harness in code. The Claude Agent SDK exposes the same layers as options: instructions as system_prompt and setting_sources (it will read your CLAUDE.md and settings files), capabilities as tools and mcp_servers, policy as permission_mode, allowed_tools and hooks.
# harness.py — a reviewer agent with one custom tool and one guard hook
import asyncio
from claude_agent_sdk import (
ClaudeAgentOptions, HookMatcher, create_sdk_mcp_server, query, tool,
)
@tool("ticket_lookup", "Fetch a support ticket by id from the internal tracker", {"ticket_id": str})
async def ticket_lookup(args):
# Replace with your real client. Return text; the model reads it.
record = {"id": args["ticket_id"], "status": "open", "summary": "Extraction misses signing date on scanned PDFs"}
return {"content": [{"type": "text", "text": str(record)}]}
async def guard(input_data, tool_use_id, context):
"""PreToolUse hook: deny anything that writes outside services/ or tests/."""
if input_data.get("tool_name") in {"Edit", "Write"}:
path = input_data.get("tool_input", {}).get("file_path", "")
if not (path.startswith("services/") or path.startswith("tests/")):
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Edits are limited to services/ and tests/, got {path}",
}
}
return {}
tracker = create_sdk_mcp_server(name="tracker", version="1.0.0", tools=[ticket_lookup])
options = ClaudeAgentOptions(
cwd="/srv/contract-pipeline",
setting_sources=["project"], # load CLAUDE.md, .claude/settings.json, skills
system_prompt="You are reviewing a bug report and proposing a fix with tests.",
mcp_servers={"tracker": tracker},
allowed_tools=["Read", "Grep", "Glob", "Edit", "Bash", "mcp__tracker__ticket_lookup"],
permission_mode="acceptEdits",
hooks={"PreToolUse": [HookMatcher(matcher="Edit|Write", hooks=[guard])]},
max_turns=40,
)
async def main():
async for message in query(prompt="Investigate ticket T-4821 and open a draft fix.", options=options):
print(message)
asyncio.run(main())
Everything in the terminal version has an equivalent here, and the repository files are still honoured because of setting_sources. The point is not the SDK. The point is that you can name each layer and point at the line that implements it.
Harnesses for work that outlives a session
Context windows end. Processes crash. A task that takes a day of agent time will span many sessions, and each new session starts with no memory of the last. Anthropic's long-running agents post describes the two failure modes exactly as we saw them: the agent tries to do too much in one go, runs out of context mid-feature and leaves a half-built mess, or it sees existing progress and declares the whole project finished too early.
The fix is a state layer the agent is told to maintain, plus a split between the first session and every later one.
The initializer runs once. It expands the task into a feature list (JSON, because models corrupt JSON less often than Markdown when editing), writes an init.sh that starts the environment, creates a progress log and makes the first commit. The worker runs on every later session and does one feature: read the progress log and the git log, pick the highest-priority feature whose passes is false, run init.sh, do a quick end-to-end check to catch what the previous session broke, implement, test, flip passes, commit, append to the log. Workers may change passes; they may not edit or delete features. The harness that drives this is small:
# sessions.py — drive a long-running task as a sequence of bounded sessions
import asyncio, json, subprocess
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, query
ROOT = Path("/srv/project")
FEATURES = ROOT / "features.json"
INIT_PROMPT = """You are starting a new project. Read SPEC.md. Produce:
1. features.json: a list of {id, category, description, test_steps, passes:false}, exhaustive, small items.
2. init.sh: starts the dev environment and dev server.
3. claude-progress.txt: an empty log with a header describing the project.
Commit everything. Do not implement features yet."""
WORK_PROMPT = """Continue the project. First: `git log --oneline -20`, read claude-progress.txt and features.json.
Run ./init.sh and do a quick end-to-end check of the app; fix anything the last session left broken.
Then pick ONE feature with passes=false (highest priority first), implement it, test it as a user would,
set passes=true only if it really passes, commit with a descriptive message, and append what you did
and what is left to claude-progress.txt. Never remove or edit feature entries. Stop after one feature."""
def remaining() -> int:
return sum(not f["passes"] for f in json.loads(FEATURES.read_text()))
async def session(prompt: str) -> None:
options = ClaudeAgentOptions(cwd=str(ROOT), permission_mode="acceptEdits",
setting_sources=["project"], max_turns=60)
async for msg in query(prompt=prompt, options=options):
pass # stream to your trace here
async def main(max_sessions: int = 50):
if not FEATURES.exists():
await session(INIT_PROMPT)
for i in range(max_sessions):
left = remaining()
print(f"session {i}: {left} features remaining")
if left == 0:
break
await session(WORK_PROMPT)
subprocess.run(["git", "-C", str(ROOT), "status", "--short"])
asyncio.run(main())
Two things made the difference when we adopted this. Making the worker verify the previous session's work before starting new work, because the last session usually ran out of context at the worst moment. And forcing "test it as a user would": agents are happy to mark a feature done after a unit test or a curl; giving them a browser tool and a rule to use it was worth more than any model upgrade that year.
Harness engineering, the discipline
OpenAI's post is worth reading in full, but the practices that transferred to our much smaller team were these. The repository is the system of record: anything the agent cannot reach from inside the repo "effectively doesn't exist", so design docs, plans and quality notes live in docs/, not in chat. Custom linters carry remediation instructions in their error messages so the agent can fix violations itself. Architectural constraints (allowed dependency directions between layers) are enforced mechanically rather than described in prose. And drift is cleaned up by background "garbage collection" agents that compare the code to a short list of golden principles and open small, auto-mergeable PRs, which replaced a weekly manual cleanup that did not scale.
None of that is exotic. It is the same engineering discipline we already apply to humans, written down precisely enough that a model can follow it, and enforced by tools rather than by review comments.
The order I build it in
If you are starting from the loop, this is the order that has paid off for us, and it is roughly the order of how expensive each layer's failures are.
First, policy and sandbox: deny rules, a read-only default, a container or a worktree the agent cannot escape. Before anything else, because the first incident is always a destructive command. Second, the trace, because you will need to explain the first incident anyway. Third, the instruction file, short, with pointers. Fourth, hooks for the two or three invariants you care most about (tests pass on stop, no edits outside the service directory). Fifth, the state layer, only when tasks start outliving a session. Sixth, evals, which get their own post.
What I would buy rather than build: the loop, the terminal UI, MCP transport, and sandboxing. What I would always build myself: the instruction files, the hooks, the tool error messages and the trace format, because they encode what is specific to your codebase and your risk, and nobody else can write them for you.
References
- Anthropic, Effective harnesses for long-running agents (November 2025)
- OpenAI, Harness engineering: leveraging Codex in an agent-first world (February 2026)
- Claude Code docs: Hooks, Settings, Skills, Agent SDK (Python)
- AGENTS.md, the open instruction-file format used by Codex, Cursor, Gemini CLI and others