The model is stateless. A runtime always runs the loop and carries or rebuilds the state. That runtime is the core of the harness, whether you operate it or a provider does. This page builds that idea from one request, with one small example carried through every section.
People mix these three in conversation. This page is about the first one.
I went through the 107 AI job postings I saved for my own search. Not a market survey. I counted how many postings mention each capability and each product.
Start with one request. A broker sends an email that asks for a homeowners insurance quote. Your program sends that email to a model.
#!/bin/sh
# One call to the Messages API. Content in; content and control signals out.
# The key comes from the environment. Never paste a real key into a file.
# The documented shape of the reply is in sample_response.json (illustrative).
curl https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2026-10-21" \
-d '{
"model": "claude-opus-5-5",
"max_tokens": 16000,
"system": "You triage inbound homeowners quote emails for an insurance carrier.",
"tools": [{
"name": "verify_address",
"description": "Check that a street address exists.",
"input_schema": {
"type": "object",
"properties": {"address": {"type": "string", "description": "Street, city and state"}},
"required": ["address"]
}
}],
"messages": [
{"role": "user", "content": "Please quote homeowners for 18 Gulf Shore Blvd, Naples FL, built 1998, 2,400 sq ft."}
]
}'curl_example.sh — one request to the Messages API. The key comes from the environment.
{
"_note": "ILLUSTRATIVE. Hand-written from the Messages API documentation to show the response shape for curl_example.sh. Not captured from a real call: ids, text and token counts are invented, and a real reply can carry extra blocks (for example a thinking block) and extra usage fields.",
"id": "msg_01EXAMPLEEXAMPLEEXAMPLE",
"type": "message",
"role": "assistant",
"model": "claude-opus-5-5",
"content": [
{
"type": "text",
"text": "I'll verify the address first."
},
{
"type": "tool_use",
"id": "toolu_01EXAMPLEEXAMPLEEXAMPLE",
"name": "verify_address",
"input": {
"address": "18 Gulf Shore Blvd, Naples FL"
}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 412,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 58
}
}sample_response.json — the documented response shape. Hand-written from the API documentation, not captured from a real call.
stop_reason is tool_use. The model did not finish. It asked your code to verify an address.The model keeps none. So a runtime must rebuild the context for every inference step (one run of the model). There are two ways.
Your code re-sends it. You keep the message history and send it with each request. The example on this page works this way.
A provider stores it. The OpenAI Responses API and Conversations API can keep the conversation on the server. Your next request refers to it by an id. The state still exists. A different runtime holds it.
The request and the response above follow the API reference. I did not run a live call while building this page. Every run shown here uses a scripted fake model that returns replies in the same response shape. The live path exists in the example and reads the key from the environment only when you ask for it.
Tool use means the model asks the harness to act. The model cannot run anything. It returns a request. Your code runs the tool and sends the result back in the next call.
def call_model(messages: list[dict]) -> dict:
"""One HTTPS POST. The model keeps nothing, so every call carries the full history."""
body = {"model": "claude-opus-5-5", "max_tokens": 16000, "tools": TOOLS, "messages": messages}
request = urllib.request.Request("https://api.anthropic.com/v1/messages", json.dumps(body).encode(), {
"x-api-key": os.environ["ANTHROPIC_API_KEY"],
"anthropic-version": "2026-10-21",
"content-type": "application/json"})
with urllib.request.urlopen(request, timeout=120) as reply:
return json.load(reply)
def run_tool(name: str, args: dict) -> str:
"""The model asked; our code acts. Here there is only one tool."""
zone = FLOOD_ZONES.get(args["address"].lower(), "X")
return json.dumps({"outcome": "quote", "premium_band": "high" if zone == "AE" else "standard",
"reasons": [f"eligible; flood zone {zone}"]})
def agent(email: str, call=call_model) -> str:
"""The loop: call, run the tools the model asked for, send the results back, repeat."""
messages = [{"role": "user", "content": email}]
while True: # no turn limit, no budget, no checkpoint: that is the harness's job
response = call(messages)
messages.append({"role": "assistant", "content": response["content"]})
if response["stop_reason"] != "tool_use":
return "".join(block["text"] for block in response["content"] if block["type"] == "text")
results = [{"type": "tool_result", "tool_use_id": block["id"],
"content": run_tool(block["name"], block["input"])}
for block in response["content"] if block["type"] == "tool_use"]
messages.append({"role": "user", "content": results})agent_minimal.py — the whole agent in 30 lines: one HTTP call, one tool, one loop. No harness jobs.
Your code. The thirty lines above. You own every decision.
A library in your process. An agent SDK or a framework runs the same loop for you.
A provider. A managed agent service runs the loop on its own servers, and may also run the tools there. Claude Managed Agents and the OpenAI Agents API work this way. Both are in beta.
Streaming does not change this. With SSE the reply arrives in pieces. That changes the transport, not the loop.
These eight jobs are a checklist for what a production agent needs around the loop. Later on this page, each framework, coding tool and managed platform covers some of them and leaves the rest to you.
All code on this page comes from one small example. It is a teaching miniature of an insurance-quote agent that I built. It is not production code.
The case. A broker emails a quote request for a house in Florida. The agent verifies the address, looks up the property and checks the underwriting rules. It returns a checked decision: quote, ask or human_review. Sending the reply email is a side effect behind a human approval gate.
The limits. About 450 lines of Python, standard library only. It runs against a scripted fake model. All vendor data is simulated in the file. The live call path follows the API reference but is not exercised here.
def wrap_email(email: str) -> dict:
"""Job 4.1: the email enters as the first user message, fenced so it reads as data."""
return {"role": "user", "content": [{"type": "text", "text": f"New inbound email.\n<email>\n{email}\n</email>"}]}
def assemble_context(caller: Caller, state: dict, model: str) -> dict:
"""Job 4.1: build the full request body. The API renders tools, then system, then messages."""
cache = {"type": "ephemeral"} # a prompt-cache breakpoint: everything up to this block is the cached prefix
system = [
{"type": "text", "text": SYSTEM_PROMPT}, # 1. same for every run
{"type": "text", "text": ROLE_INSTRUCTIONS[caller.role], "cache_control": cache}, # 2. same per role
{"type": "text", "text": "Underwriting rules:\n" + retrieve_rules(caller, state["email"]),
"cache_control": cache}, # 3. same within a run
]
return {"model": model, "max_tokens": MAX_TOKENS, "system": system,
"tools": tool_schemas_for(caller), # stable order, so the prefix stays byte-identical
"messages": state["messages"]} # 4. the history: it changes every step, so it comes lastharness.py — wrap_email and assemble_context. The email is fenced as data; the history comes last because it changes every step.
Context is five things: instructions, tool definitions, retrieved data, conversation history and memory. Every inference step must rebuild it. Your code sends it, a provider-managed state refers to it, or a cache reuses it.
The order matters for prompt caching (reusing an unchanged prefix at a lower price). A cache matches a prefix exactly. So the parts that never change go first, and the part that changes every step goes last.
Retrieval in the example is a keyword match over three documents in the file. A real system uses search, often RAG (retrieve documents first, then ask the model). Instruction files such as CLAUDE.md and AGENTS.md are the same job inside a coding harness.
def run_tool_guarded(name: str, args: dict, timeout_s: float = 2.0) -> tuple[str, bool]:
"""Job 4.2: run one tool behind two guards. Returns (result text, is_error).
The guards are an allowlist and a wait limit. This is NOT a sandbox: the tool runs in our process,
and a call that times out is abandoned, not killed. Real systems run tools in a container or microVM.
"""
if name not in TOOL_ALLOWLIST:
return f"blocked: {name} is not on the tool allowlist", True
pool = ThreadPoolExecutor(max_workers=1)
try:
return json.dumps(pool.submit(TOOL_ALLOWLIST[name], **args).result(timeout=timeout_s)), False
except TimeoutError:
return f"{name} timed out after {timeout_s}s", True
except Exception as error: # e.g. the model sent wrong arguments; tell it, so it can correct itself
return f"{name} failed: {error}", True
finally:
pool.shutdown(wait=False)harness.py — run_tool_guarded. An error goes back to the model as text, so the model can correct itself.
MCP (Model Context Protocol) is a common protocol for exposing tools and data. Compatible clients can reuse the same server, within the features and transport each client supports. The current specification uses stateless, self-contained requests. Explanations that describe a session handshake are out of date.
In the example the tool runner has two guards: an allowlist and a wait limit. That shows where the boundary sits, but it is not a sandbox. The tool runs in the same process, and a call that times out is abandoned, not killed. A real system runs tools in a container or a microVM (a very small virtual machine), so a bad call cannot reach the host and can be stopped.
The send_reply tool is not on the tool allowlist. Sending email is a side effect. It goes through the approval gate in 4.5 and the send-once guard in 4.7.
def run(db: sqlite3.Connection, model, state: dict, *, approve: bool = False,
crash_after: int | None = None, verbose: bool = False) -> dict:
"""Job 4.3: the loop. One step is one model call or one batch of tool calls."""
caller = Caller(**state["caller"])
if state["status"] == "waiting_approval":
state["status"] = "running"
while state["status"] == "running":
if over_budget(state): # stop: token budget (4.7)
state["status"] = "stopped:token_budget"
elif pending_tool_calls(state):
state["step"] += 1
tool_step(db, state, caller, approve, verbose) # may stop: waiting_approval (4.5)
elif state["turn"] >= state["max_turns"]: # stop: max turns
state["status"] = "stopped:max_turns"
else:
state["step"] += 1
model_step(model, state, caller, verbose) # may stop: done, refusal, invalid output
if crash_after == state["step"]:
raise SystemExit(f"simulated crash in step {crash_after}, before its checkpoint. "
f"Continue with: --resume {state['run_id']}")
checkpoint(db, state) # durability (4.4)
return stateharness.py — run. Every exit from the loop is a named status, and every step ends with a checkpoint.
Larger harnesses add branches and delegation. A subagent is a second loop with its own context window for one sub-task. A graph runtime such as LangGraph makes each branch an explicit node. The example has one loop and no subagents.
Choosing which model to call is not control flow. It belongs to reliability and cost (4.7).
def checkpoint(db: sqlite3.Connection, state: dict) -> None:
"""Job 4.4: save the whole run state after every step. The model remembers nothing; this row does."""
db.execute("INSERT OR REPLACE INTO checkpoints VALUES (?, ?, ?, ?)",
(state["run_id"], state["step"], state["status"], json.dumps(state)))
db.commit()
def load_checkpoint(db: sqlite3.Connection, run_id: str) -> dict:
"""Job 4.4: resume = load the newest checkpoint and enter the same loop."""
row = db.execute("SELECT state FROM checkpoints WHERE run_id = ? ORDER BY step DESC LIMIT 1", (run_id,)).fetchone()
if not row:
raise SystemExit(f"no checkpoint found for {run_id}")
return json.loads(row[0])harness.py — checkpoint and load_checkpoint. The model remembers nothing; this row does.
Memory across runs is the same job with a longer life: facts that should survive after this run ends. The example keeps state for one run only.
A provider can hold this state for you: stored conversations, managed sessions or a durable execution service. The job does not go away. The owner changes.
def tool_schemas_for(caller: Caller) -> list[dict]:
"""Job 4.5: the model only sees the tools this role may run."""
return [schema for schema in TOOL_SCHEMAS if schema["name"] in ROLE_TOOLS[caller.role]]
def may_read(caller: Caller, doc: dict) -> bool:
"""Job 4.5: the role decides which documents may enter the context."""
return caller.role in doc["roles"]
def screen_untrusted(text: str) -> list[str]:
"""Job 4.5: a simple prompt-injection check. Email text is data; flag text that reads like orders."""
return [pattern for pattern in INJECTION_PATTERNS if re.search(pattern, text, re.IGNORECASE)]
def authorize(caller: Caller, tool: str, state: dict, approved: bool) -> str:
"""Job 4.5: the gate before every tool call. Returns "allow", "wait" or a denial reason."""
if tool not in ROLE_TOOLS[caller.role]: # checked again here: never trust the model to stay in its list
return f"denied: role {caller.role} may not run {tool}"
if tool in SIDE_EFFECT_TOOLS:
if state["tainted"]:
return "denied: the email contains instruction-like text; a human must review it"
if not approved:
return "wait"
return "allow"harness.py — tool_schemas_for, may_read, screen_untrusted and authorize. The role is never set by the model or by the email.
Prompt injection is text from an outside sender that reads like an instruction. The email is untrusted input, so the harness treats it as data.
The injection check in the example is four regular expressions. It shows where the check sits. It does not show how to make the check complete. The real defence is structural: the email is fenced as data, a flagged run cannot send, and the decision is forced to human review.
The fake model is written to obey an injected instruction on purpose. That gives the harness something to stop.
def validate_decision(text: str) -> tuple[dict | None, list[str]]:
"""Job 4.6: check the final answer against the contract. Returns (decision, errors).
Hand-written to show the idea. The API can also enforce a JSON Schema for you
(structured outputs: output_config.format).
"""
try:
data = json.loads(text)
except json.JSONDecodeError:
return None, ["the reply is not a single JSON object"]
if not isinstance(data, dict):
return None, ["the reply is not a JSON object"]
errors = []
if data.get("decision") not in DECISIONS:
errors.append(f"decision must be one of {DECISIONS}")
for key in ("missing_fields", "reasons"):
if not isinstance(data.get(key), list) or not all(isinstance(item, str) for item in data[key]):
errors.append(f"{key} must be a list of strings")
if data.get("decision") == "quote" and data.get("premium_band") not in BANDS:
errors.append(f"a quote needs premium_band, one of {BANDS}")
if extra := set(data) - {"decision", "premium_band", "missing_fields", "reasons"}:
errors.append(f"unknown keys: {sorted(extra)}")
return (None, errors) if errors else (data, [])
def repair_prompt(errors: list[str]) -> str:
"""Job 4.6: one repair retry. Tell the model exactly what failed."""
return ("Your last reply did not match the output contract: " + "; ".join(errors)
+ ". Reply again with only the JSON object.")harness.py — validate_decision and repair_prompt. Hand-written here to show the idea.
The check here is written by hand. A model API can also enforce a JSON Schema for you, a feature usually called structured output. Either way the harness still decides what to do when the output is valid but wrong for the business.
def pick_model(failed_attempts: int) -> str:
"""Job 4.7: routing and fallback. After two failures, try the other model (its cache is separate)."""
return PRIMARY_MODEL if failed_attempts < 2 else FALLBACK_MODEL
def call_with_retry(model, build_body) -> dict:
"""Job 4.7: retry 429 and 5xx with exponential backoff. Other errors are bugs; raise them."""
for attempt in range(MAX_ATTEMPTS):
try:
return model.create(build_body(pick_model(attempt)))
except urllib.error.HTTPError as error:
retryable = error.code == 429 or error.code >= 500 # 529 = overloaded
if not retryable or attempt == MAX_ATTEMPTS - 1:
raise
header = error.headers.get("retry-after") or "" # seconds, or an HTTP date (then use backoff)
wait = float(header) if header.replace(".", "", 1).isdigit() else BACKOFF_BASE_S * 2 ** attempt
print(f"[retry] HTTP {error.code}, attempt {attempt + 1}, waiting {wait:.2f}s", file=sys.stderr)
time.sleep(wait)
raise AssertionError("unreachable")harness.py — pick_model and call_with_retry. Rate limits and server errors are retried; other errors are bugs.
def over_budget(state: dict) -> bool:
"""Job 4.7: token budget guard, fed by the usage numbers of every response."""
return state["tokens"] >= state["token_budget"]
def send_once(db: sqlite3.Connection, run_id: str, email: dict) -> str:
"""Job 4.7: idempotency key. The same email in the same run is recorded once, so a resume cannot resend."""
key = hashlib.sha256(json.dumps([run_id, email], sort_keys=True).encode()).hexdigest()[:16]
inserted = db.execute("INSERT OR IGNORE INTO outbox VALUES (?, ?, ?)", (key, run_id, json.dumps(email)))
db.commit() # simulated send: a real system passes this key to a mail API that honours it, or uses an outbox worker
return json.dumps({"status": "sent" if inserted.rowcount else "duplicate_suppressed", "idempotency_key": key})harness.py — over_budget and send_once. The budget is a guardrail checked between calls, not a hard cap. The send is simulated: the key is recorded once, so a resume cannot resend.
Prompt caching lowers the price of an unchanged prefix. It is not free: writing to the cache costs more than normal input. Section 5 has the arithmetic.
Model routing and fallback belong to this job. Note the comment in the code: the fallback model has its own separate cache, so the first fallback call cannot read the prefix that the primary model cached.
def trace(state: dict, verbose: bool, **event) -> None:
"""Job 4.8: one JSON line per model response and per tool call. evals.py is the second half."""
line = json.dumps({"run_id": state["run_id"], "step": state["step"], "turn": state["turn"], **event})
with open(TRACE_FILE, "a") as file:
file.write(line + "\n")
if verbose:
print(line, file=sys.stderr)harness.py — trace. Step, stop reason, tokens, cache reads and latency, one JSON line each.
def run_case(case: dict) -> dict:
"""Run one golden email through the real harness loop and return what happened."""
db = harness.open_db(":memory:") # a throwaway database: the outbox is not a real mailbox
caller = harness.Caller(user="eval", role=case["role"])
state = harness.start_run(db, case["email"], caller)
state = harness.run(db, FakeModel(), state, approve=True) # the eval approves, so only policy can block
decision = state["decision"] or {}
return {"status": state["status"], "decision": decision.get("decision"),
"premium_band": decision.get("premium_band"), "missing_fields": decision.get("missing_fields"),
"reply": state["reply"], "emails_sent": db.execute("SELECT COUNT(*) FROM outbox").fetchone()[0]}
def grade(expect: dict, actual: dict) -> list[str]:
"""Compare against the expected values. Returns a list of failures (empty = pass)."""
failures = [f"{key}: expected {expect[key]!r}, got {actual[key]!r}" for key in expect if expect[key] != actual[key]]
if actual["status"] != "done":
failures.append(f"status: expected 'done', got {actual['status']!r}")
if (actual["emails_sent"] == 1) != (expect["reply"] == "sent"): # sent, but not expected to be (or the reverse)
failures.append(f"outbox: {actual['emails_sent']} email(s) sent, expected reply {expect['reply']!r}")
return failuresevals.py — run_case and grade. The script exits with an error below the minimum score, so a CI job can block the change.
The golden set has five emails and runs on the fake model. It is free and gives the same result every time. It tests the harness: the gates, the policy and the send-once guard. It does not measure the quality of a real model.
A full eval adds a rubric (written scoring rules), a second model as a judge, and scoring of the traces. It runs in CI (the automated checks on every change).
The model is stateless, so every step consumes the context again. A hub becomes slow and expensive when it puts every connected tool and document into every call.
These are levers with trade-offs. They are not a proof that a broad hub is cheap.
Lever 4 uses compaction (replacing long history with a summary) and subagents.
Anthropic publishes these multipliers: a cache write costs 1.25 times normal input with a five-minute lifetime, and a cache read costs 0.1 times. Take a stable prefix of 20,000 tokens.
A coding harness is a vendor-built agent harness for software work. Each one uses its own names. Under the names are the same jobs. This table translates between them.
| Feature, and what it means | Claude Code | Codex CLI | GitHub Copilot CLI | Antigravity CLI (replaces Gemini CLI) | Cursor and Cursor CLI | Grok Build |
|---|---|---|---|---|---|---|
Project instructions file A text file the harness loads into the context at the start of every session. Job 1 | CLAUDE.md (can also read AGENTS.md); auto memory | AGENTS.md | .github/copilot-instructions.md, .github/instructions/**/*.instructions.md, AGENTS.md; also reads CLAUDE.md and GEMINI.md | Rules: AGENTS.md, GEMINI.md, .agents/rules/*.md. Gemini CLI: GEMINI.md | Rules: .cursor/rules/*.mdc, AGENTS.md, User Rules, Team Rules | AGENTS.md, read from the working directory up to the repository root |
Skills and custom commands Instructions the harness loads only when a task needs them. Job 1 | Skills (SKILL.md), commands, plugins | Skills, slash commands, plugins | Agent skills (SKILL.md folders), custom agents, plugins | Agent Skills, Antigravity plugins. Gemini CLI custom commands import as skills | Skills, plugins | Skills (SKILL.md in .grok/skills/), shown as /<skill-name>; plugins and marketplaces |
Hooks Your own script that the harness runs at a fixed point in the loop, for example before a tool call. Jobs 3 and 5 | Hooks in settings; events such as PreToolUse | Hooks (hooks.json or [hooks] in config.toml): SessionStart, PreToolUse, PostToolUse, UserPromptSubmit, Stop | Hooks (.github/hooks/NAME.json): sessionStart, preToolUse, postToolUse | JSON hooks (.agents/hooks.json, ~/.gemini/config/hooks.json): SessionStart, PreToolUse, PostToolUse, Stop. Gemini CLI: hooks | Hooks (.cursor/hooks.json): sessionStart, preToolUse, beforeShellExecution, afterFileEdit, stop | Hooks (.grok/hooks/); project hooks need /hooks-trust |
Permission modes and sandbox What the agent may do without asking, and the boundary its commands run inside. Jobs 5 and 2 | Modes default, acceptEdits, plan, auto, dontAsk, bypassPermissions; sandboxed Bash tool | Approval policy on-request, never, granular. Sandbox modes read-only, workspace-write, danger-full-access | --allow-tool, --allow-all-tools, --deny-tool; trusted directories; plan and autopilot modes; /sandbox enable | --mode accept-edits|auto|plan, --sandbox. Gemini CLI: policy engine (allow, deny, ask_user), trusted folders, sandbox | Modes Agent, Plan, Ask; permissions.allow and deny in .cursor/cli.json; --sandbox enabled|disabled | Modes ask, auto, always-approve; plan mode /plan. Sandbox is off by default: --sandbox <profile> (workspace, read-only, strict), Landlock on Linux, Seatbelt on macOS |
MCP Connects outside tool servers through the Model Context Protocol. Job 2 | Yes | Yes | Yes; ships GitHub's MCP server; repo file .mcp.json | Yes (both) | Yes | Yes (/mcps) |
Subagents A second agent loop with its own context window for one sub-task. Jobs 3 and 1 | Custom subagents; agent teams | Subagents | Subagents; custom agents (.github/agents/*.agent.md); /delegate, /fleet | Subagents (/agents) | Subagents (.cursor/agents/); Cloud Agents handoff | Subagents (/tasks lists them) |
Headless mode Runs the same loop from a script with no person at the keyboard, for example in CI. The loop | claude -p | codex exec | copilot -p | agy -p. Gemini CLI: gemini -p | agent -p | grok -p |
These products call themselves a harness in their own documentation. Each entry says what the vendor means by it.
OpenAI published a post titled “Harness engineering: leveraging Codex in an agent-first world”. Anthropic published “Effective context engineering for AI agents” in September 2025. I found no official source that says who used either term first, so this page does not say it.
Products overlap, so exclusive categories do not work. Each row has a primary role. Each cell says who does that job: the product, a partner, the vendor as a service, or you.
| Product and primary role | 1 Context | 2 Tools + sandbox | 3 Control flow | 4 State + durability | 5 Identity + approval | 6 Output contract | 7 Reliability + cost | 8 Observability + evals |
|---|---|---|---|---|---|---|---|---|
LangChain (create_agent) Agent framework | Built inmiddleware; Deep Agents adds context compression | Built intools Built inshell sandbox: DockerExecutionPolicy | Built inthe loop runs on LangGraph | Built inpersistence through LangGraph | Built inhuman-in-the-loop identity: — docs do not say | Built inresponse_format: ToolStrategy, ProviderStrategy | Built inretry middleware, ModelFallbackMiddleware, call limits | IntegratesLangSmith |
LlamaIndex Agent framework (data-centric) | Built inretrieval over your data | Built intools Integratessandbox: Azure Dynamic Sessions, AgentCore | Built inevent-driven Workflows | Built instate, memory, durable workflows IntegratesDBOS | Built inhuman review step identity: — docs do not say | Built instructured output, Pydantic programs | Built inworkflow retries Built inTokenCountingHandler, token_budget | Built ininstrumentation, evaluation modules |
CrewAI Agent framework | Built inmemory, knowledge | Built intools, MCP Integratessandbox: E2B, Daytona tools | Built inCrews + Flows | Built inmemory, checkpointing | Built inhuman input identity: — docs do not say | Built inoutput_pydantic, output_json | Built inmax_rpm, max_retry_limit, tool cache | ManagedCrewAI AMP: tracing, observability |
Pydantic AI Agent framework (typed) | Built ininstructions, typed dependencies | Built intools, MCP IntegratesPydantic AI Harness sandboxes: Docker, E2B, Modal | Built inloop; Pydantic Graph | IntegratesTemporal, DBOS, Prefect | Built intool approval identity: — docs do not say | Built invalidated output types | Built infallback model, validation retries | Built inOpenTelemetry, Pydantic Evals IntegratesLogfire |
Microsoft Agent Framework Agent framework | Built incontext providers; compaction in the Agent Harness | Built intools, MCP; shell tools are pre-release | Built inagents + graph workflows | Built insessions; workflow checkpointing, time-travel | Built intool approval identity: — docs do not say | Built inresponse_format, RunAsync<T> | You buildretry and fallback written as middleware | Built inOpenTelemetry Integratesevaluation services |
LangGraph Orchestration runtime | Built inruntime context, state, store | Built inToolNode (prebuilt) sandbox: — docs do not say | Built ingraph runtime | Built incheckpointers (InMemorySaver, SqliteSaver, PostgresSaver); Store for cross-thread memory | Built inhuman-in-the-loop: pause, edit state, resume identity: — docs do not say | IntegratesLangChain models: with_structured_output | Built indurable execution, RetryPolicy, TimeoutPolicy, node caching | IntegratesLangSmith |
Claude Agent SDK Agent SDK | Built inClaude Code context management, CLAUDE.md memory, skills | Built inbuilt-in tools, MCP, sandbox settings You buildcontainer isolation is your deployment | Built inagent loop, subagents, hooks | Built insessions, external session storage, file checkpointing | Built inpermissions, approvals and user input | Built instructured outputs | Built incost tracking, max_budget_usd, fallback_model, API retries | Built inOpenTelemetry evals: — docs do not say |
OpenAI Agents SDK Agent SDK | Built inlocal context, dynamic instructions, sessions | Built intools, MCP, hosted tools, sandbox agents | Built inloop, handoffs, agents as tools | Built insessions: SQLite, SQLAlchemy, Redis and others | Built inguardrails, human in the loop You buildtools enforce authorization themselves | Built inoutput_type | Built inopt-in retries (ModelSettings.retry), usage tracking, max_turns | Built intracing evals: — docs do not say |
Google ADK Agent SDK | Built incontext caching, context compaction | Built intools, MCP, OpenAPI Built incode_executor; GKE Code Executor (gVisor) | Built ingraph Workflow Runtime, multi-agent | Built insessions, state, memory, resumable runs | Built intool confirmation Built intool authentication: auth schemes, credentials | Built inoutput_schema | Built inretry in workflows, context caching | Built inevaluation; logging, metrics, traces |
Strands Agents Agent SDK | Built inharness: context management, caching | Built intools, MCP Built inSandbox: Docker, SSH | Built inlifecycle controls, multi-agent patterns | Built inmemory, sessions | Built inguardrails Built inHumanInTheLoop intervention, interrupts | Built in | Built inturn limits, token budgets, model portability | Built intracing, evals |
Claude Managed Agents Managed harness · Beta | Managedprompt caching, compaction | Managedcloud sandbox or self-hosted sandbox; Bash, files, web, MCP | ManagedAnthropic runs the loop | Managedstateful sessions, event history | Managedpermission policies, vaults | The official docs I read do not say | Managedprompt caching, session budgets retries: — docs do not say | ManagedConsole session viewer, usage, OpenTelemetry tracing |
OpenAI Agents API Managed harness · Beta | Managedcontext compaction | ManagedOpenAI-hosted or self-hosted sandbox; MCP, web search, skills | Managedmanaged Codex harness, subagents | Managedsessions, recovery | Managedvaults, auto_review setting | The official docs I read do not say | Managedrecovery cost: — docs do not say | Managedtracing on by default, OTLP export |
Amazon Bedrock AgentCore Managed agent platform | ManagedHarness: truncation, summarization, prompt caching | ManagedGateway, Code Interpreter, Browser; one microVM per session | ManagedHarness module: a managed agent loop | ManagedMemory: short and long term | ManagedIdentity, Policy | The official docs I read do not say | ManagedmaxIterations, timeoutSeconds, maxTokens | ManagedObservability, Evaluations, Optimization |
Microsoft Foundry Agent Service Managed agent platform | Managedautomatic truncation to the model context | ManagedToolboxes, code interpreter, MCP | ManagedAgent Runtime; optional Microsoft Copilot harness (preview) | Managedconversations, session state | ManagedEntra agent identity, RBAC, content filters | ManagedJSON Schema text format | You buildretry 429 errors with backoff | Managedtracing, metrics, evaluations |
Agent Runtime (Gemini Enterprise Agent Platform) Managed agent platform | The official docs I read do not say | Managedruntime | The official docs I read do not say | ManagedAgent Platform Sessions, Memory Bank | ManagedAgent Identity, Agent Gateway | The official docs I read do not say | The official docs I read do not say | Managedagent evaluation, observability, trace viewer |
OpenAI Responses API + Conversations API Model API with optional state | Managedoptional server-side compaction | Managedbuilt-in tools; shell in hosted containers | The official docs I read do not say | Managedresponses stored 30 days by default; conversation objects persist | Not its job | Built inStructured Outputs: text.format json_schema | Built inprompt caching | IntegratesEvals API reads Responses data |
Temporal Durable execution | Not its job | Not its job | Built inWorkflows as code | Built indurable execution | Not its job | Not its job | Built inautomatic retries | Built inSDK metrics, OpenTelemetry tracing, Visibility |
LangSmith (with Agent Server) Tracing and evals (also deployment) | Not its job | Not its job | Not its job | ManagedAgent Server: threads, runs, durable execution | Built inAgent Server custom auth, access control | Not its job | Built inLLM Gateway; details not stated | Built intracing, dashboards, alerts, evaluation |
Langfuse Tracing and evals | Built inpartial: prompt management | Not its job | Not its job | Not its job | Not its job | Not its job | Built inAI Gateway with spend tracking | Built intracing, evals, datasets |
Arize Phoenix Tracing and evals | Built inpartial: prompt management, playground | Not its job | Not its job | Not its job | Not its job | Not its job | Not its job | Built intracing, evals, datasets and experiments |
LiteLLM Gateway | Not its job | Not its job | Not its job | Not its job | Built invirtual keys, guardrails | Not its job | Built inretry and fallback router, load balancing, caching, spend tracking | Integrateslogging to other tools |
These three are not scored in the table. They are how the boxes connect.
The first principle turns build-or-adopt into one question per job: who runs it? You do not choose one product for all eight jobs. You choose an owner for each job.
A common shape: an SDK for the loop, a durable execution service for long waits, a tracing tool for job 8, and your own code for identity.
In my experience three things stay with you whatever you adopt: the golden set, the permission model, and the decision about which data may enter the context. They describe your business, not the vendor's product.
Many companies want one AI hub for people who are not engineers: finance, operations, support, sales. A hub is a harness plus three things around it.
For the people side of this, see the role-by-role adoption strategy and the full working version at enable.sammuthu.com.
This page is the mental model. These pages are the detail. Each link names the section or job it deepens.