TL;DR
An Agent Harness implementation is the code between a model proposal and an authoritative effect. It persists run state, narrows capabilities, evaluates policy, pauses for durable approval, executes tools with stable operation identities, records observable evidence, and recovers without guessing whether an external write happened.
MCP can standardize capability discovery, and LangGraph can provide checkpoints and interrupts. Neither one owns your end-user authorization or downstream business invariants. Build those contracts first, then map them onto a framework.
Table of Contents
- Define the implementation contract
- Separate proposal, policy, and effect
- Persist runs and side effects
- Build a minimal Harness Kernel
- Map the contract to LangGraph
- Put MCP behind the policy boundary
- Make Human-in-the-Loop approval durable
- Recover from unknown outcomes
- Emit evidence without leaking data
- Test the implementation before release
- Frequently Asked Questions
Key Takeaways
- The model proposes an action; deterministic controls decide whether and how it executes.
- A run checkpoint and an external side effect are two different consistency domains.
- Every mutating call needs a stable operation key and an explicit
unknownoutcome. - Approval must bind to the exact actor, resource, arguments, schema, policy, and expiry.
- MCP schemas validate message shape, not end-user authority or business meaning.
- Framework replay can re-enter code, so non-deterministic work and effects need isolation.
- Record observable events and result digests, not private chain-of-thought or raw secrets.
- Release only after restart, duplicate-delivery, stale-approval, denial, and timeout tests pass.
Define the Implementation Contract
An Agent Harness should begin with ownership, not a framework choice. The architecture guide explains the control, execution, state, and evidence planes; this practical guide turns those planes into interfaces and failure rules.
Write a one-page contract before implementation:
| Concern | Required owner | Minimum invariant |
|---|---|---|
| Identity | Request gateway or identity service | Resolve principal and tenant before selecting capabilities |
| Proposal | Model adapter | Return a typed proposal; never mark it authorized |
| Capability registry | Harness control plane | Pin tool name, schema version, risk class, and executor |
| Policy | Independent policy service | Evaluate actor, purpose, resource, operation, and current policy |
| Approval | Durable approval service | Bind the decision to one immutable proposed effect |
| Execution | Scoped worker or tool server | Enforce arguments, credentials, timeout, and output bounds |
| Run state | Versioned state store | Accept only the expected state transition |
| Effect state | Effect journal and downstream service | Deduplicate or reconcile each semantic operation |
| Evidence | Event sink | Reconstruct decisions without storing unnecessary sensitive content |
The first implementation can be one process and one database. Service boundaries are optional; ownership boundaries are not.
Separate Proposal, Policy, and Effect
A safe execution path has three distinct artifacts:
- Proposal: what the model requested, including tool version and typed arguments.
- Policy decision: whether the authenticated actor may perform that operation on that resource for the stated purpose.
- Effect result: what the downstream system actually committed.
Do not collapse these artifacts into one tool_call object. A valid JSON object may still reference another tenant. An approved action may become stale after its resource changes. A successful HTTP response may still contain an application-level failure.
Persist Runs and Side Effects
Durable run state answers "where may the workflow resume?" An effect journal answers "what may already have happened outside the workflow?" You need both.
CREATE TABLE agent_runs (
run_id TEXT PRIMARY KEY,
state_version INTEGER NOT NULL,
status TEXT NOT NULL,
principal_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
policy_version TEXT NOT NULL,
checkpoint_json TEXT NOT NULL
);
CREATE TABLE agent_effects (
operation_key TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
call_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
tool_version TEXT NOT NULL,
argument_digest TEXT NOT NULL,
status TEXT NOT NULL,
result_digest TEXT,
external_reference TEXT
);
Use compare-and-set updates on state_version; otherwise two workers can both advance the same run. Keep effect states explicit:
prepared -> dispatched -> committed
-> failed
-> unknown -> reconciled_committed | reconciled_absent
unknown is not a cosmetic error label. It prevents a timeout from being interpreted as permission to repeat a payment, deployment, email, ticket, or repository write.
Build a Minimal Harness Kernel
The following Python 3.11 example is runnable with the standard library. It deliberately excludes a model and network transport: any provider or MCP client must first adapt its output into the same Proposal contract.
from __future__ import annotations
from dataclasses import dataclass, field
from hashlib import sha256
import json
from typing import Callable
@dataclass(frozen=True)
class Principal:
subject: str
tenant: str
scopes: frozenset[str]
@dataclass(frozen=True)
class Proposal:
call_id: str
tool: str
tool_version: str
arguments: dict[str, object]
@dataclass(frozen=True)
class Approval:
proposal_digest: str
subject: str
approver: str
expires_at: int
policy_version: str
@dataclass
class Run:
run_id: str
principal: Principal
policy_version: str
authorized_approvers: frozenset[str]
max_steps: int
steps: int = 0
effects: dict[str, dict[str, object]] = field(default_factory=dict)
events: list[dict[str, object]] = field(default_factory=list)
TOOLS = {
"read_ticket": {"scope": "ticket:read", "risk": "read"},
"close_ticket": {"scope": "ticket:write", "risk": "write"},
}
def digest(proposal: Proposal) -> str:
payload = {
"call_id": proposal.call_id,
"tool": proposal.tool,
"tool_version": proposal.tool_version,
"arguments": proposal.arguments,
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
return sha256(encoded).hexdigest()
def authorize(run: Run, proposal: Proposal) -> str:
metadata = TOOLS.get(proposal.tool)
if metadata is None:
return "deny"
if metadata["scope"] not in run.principal.scopes:
return "deny"
if proposal.arguments.get("tenant") != run.principal.tenant:
return "deny"
return "approval_required" if metadata["risk"] == "write" else "allow"
def execute(
run: Run,
proposal: Proposal,
tool: Callable[[dict[str, object], str], dict[str, object]],
*,
now: int,
approval: Approval | None = None,
) -> dict[str, object]:
run.steps += 1
if run.steps > run.max_steps:
raise RuntimeError("step budget exceeded")
decision = authorize(run, proposal)
run.events.append({"kind": "policy", "call_id": proposal.call_id,
"decision": decision})
if decision == "deny":
raise PermissionError("tool proposal denied")
proposal_digest = digest(proposal)
if decision == "approval_required":
valid = (
approval is not None
and approval.proposal_digest == proposal_digest
and approval.subject == run.principal.subject
and approval.approver in run.authorized_approvers
and approval.policy_version == run.policy_version
and approval.expires_at >= now
)
if not valid:
raise PermissionError("valid approval required")
operation_key = (
f"{run.run_id}:{proposal.call_id}:"
f"{proposal.tool}:{proposal.tool_version}"
)
previous = run.effects.get(operation_key)
if previous is not None:
return previous
run.effects[operation_key] = {"status": "dispatched"}
try:
result = tool(proposal.arguments, operation_key)
except TimeoutError:
run.effects[operation_key] = {"status": "unknown"}
raise
except Exception:
run.effects[operation_key] = {"status": "failed"}
raise
record = {
"status": "committed",
"result": result,
"result_digest": sha256(
json.dumps(result, sort_keys=True).encode()
).hexdigest(),
}
run.effects[operation_key] = record
run.events.append({"kind": "effect", "operation_key": operation_key,
"status": "committed"})
return record
external_calls: dict[str, dict[str, object]] = {}
def close_ticket(
arguments: dict[str, object],
operation_key: str,
) -> dict[str, object]:
if operation_key not in external_calls:
external_calls[operation_key] = {
"ticket_id": arguments["ticket_id"],
"status": "closed",
}
return external_calls[operation_key]
principal = Principal(
subject="user-42",
tenant="acme",
scopes=frozenset({"ticket:write"}),
)
run = Run(
run_id="run-7",
principal=principal,
policy_version="policy-3",
authorized_approvers=frozenset({"reviewer-5"}),
max_steps=3,
)
proposal = Proposal(
call_id="call-9",
tool="close_ticket",
tool_version="2",
arguments={"tenant": "acme", "ticket_id": "T-100"},
)
approval = Approval(
proposal_digest=digest(proposal),
subject="user-42",
approver="reviewer-5",
expires_at=2_000_000_000,
policy_version="policy-3",
)
first = execute(run, proposal, close_ticket, now=1_900_000_000,
approval=approval)
replayed = execute(run, proposal, close_ticket, now=1_900_000_001,
approval=approval)
assert first == replayed
assert len(external_calls) == 1
assert first["status"] == "committed"
print(json.dumps({"result": first, "external_calls": len(external_calls)}))
Expected output:
{"result": {"status": "committed", "result": {"ticket_id": "T-100", "status": "closed"}, "result_digest": "<sha256>"}, "external_calls": 1}
This example proves only a narrow contract: a scoped write needs a matching approval, and replaying the same operation does not call the downstream effect twice. Replace the in-memory dictionaries with transactional storage, authenticate the approver, and make the downstream service enforce the same operation identity.
Map the Contract to LangGraph
LangGraph can implement the workflow boundary, but its replay semantics must shape your node design. Current LangGraph documentation states that a resumed graph starts again from an appropriate node or entrypoint, not from the exact interrupted line.
Map responsibilities explicitly:
| Harness contract | LangGraph mechanism | Application responsibility |
|---|---|---|
| Versioned run cursor | Checkpointer plus stable thread_id |
Tenant isolation, retention, migrations, concurrency |
| Durable pause | interrupt() and Command(resume=...) |
Authenticated reviewer and approval binding |
| Side-effect isolation | Task or dedicated node | Idempotency key, reconciliation, downstream invariant |
| Recovery policy | Resume with same thread identity | Classify retryable, terminal, and unknown outcomes |
| Shutdown | Cooperative drain in supported versions | Hard timeout, task cancellation, worker supervision |
Three implementation rules follow:
- Put each non-deterministic call or side effect in its own task or node.
- Assume code before an interrupt can run again after resume.
- Use a durable production checkpointer; an in-memory saver demonstrates flow but does not survive process loss.
LangGraph currently exposes exit, async, and sync durability modes with different persistence costs. Treat those names and semantics as versioned framework behavior, not a portable Harness standard.
Put MCP Behind the Policy Boundary
MCP standardizes discovery and invocation; it should sit behind the Harness policy boundary. For each discovered tool, create a registry snapshot containing:
{
"server_id": "support-read",
"tool_name": "get_ticket",
"tool_version": "7",
"schema_digest": "sha256:...",
"risk_class": "sensitive_read",
"required_scope": "ticket:read",
"allowed_tenants": ["acme"],
"max_result_bytes": 65536
}
The execution sequence should be:
- Discover capabilities from an allowlisted server.
- Validate and pin the schema snapshot before exposing it to the model.
- Validate the proposal against that snapshot.
- evaluate actor, tenant, resource, purpose, and current policy;
- obtain approval for the immutable effect when required;
- invoke the server with a scoped identity and bounded timeout;
- validate, redact, and size-limit the result before returning it to model context.
For HTTP transports, the MCP 2026-07-28 authorization specification requires resource-bound token handling and forbids accepting or transiting tokens issued for other resources. For stdio, credentials come from the process environment rather than the HTTP authorization flow. In both cases, the tool server must enforce its own authorization; the model-facing schema is not a permission.
Split servers by trust boundary when that creates real isolation, such as read-only support data, write-capable ticket operations, and external web access. Do not assume that separate server names isolate identities if they still share one overprivileged credential.
Make Human-in-the-Loop Approval Durable
A durable approval is data, not a blocking terminal prompt. Persist a signed or server-authenticated decision envelope:
{
"approval_id": "approval-17",
"run_id": "run-7",
"call_id": "call-9",
"actor": "user-42",
"approver": "reviewer-5",
"resource": "ticket:T-100",
"tool_schema_digest": "sha256:...",
"argument_digest": "sha256:...",
"policy_version": "policy-3",
"decision": "approve",
"expires_at": "<timestamp>"
}
The worker should persist waiting_for_approval, release compute, and resume the same run after a decision arrives. Revalidate every bound field immediately before dispatch. Reject the decision if arguments, resource state, tool schema, policy, approver authority, or expiry changed.
Human review and automated guardrails solve different problems. Guardrails can reject malformed input or tool results. Approval records accountable consent for a specific sensitive effect. Neither replaces downstream authorization.
Recover from Unknown Outcomes
Recovery depends on where failure occurred:
| Failure point | Known fact | Correct next action |
|---|---|---|
| Before dispatch | Tool was not called | Resume or retry after current policy check |
| During model call | No authoritative effect should exist | Retry within model and budget policy |
| Tool returned a declared failure | Effect contract says failed | Record failure; retry only if classified safe |
| Timeout after dispatch | Commit status is unknown | Query downstream by operation key; do not blind retry |
| Effect committed, checkpoint failed | Downstream may contain the result | Reconcile and attach existing external reference |
| Approval expired while paused | Old consent is invalid | Re-evaluate policy and request a new approval |
| Tool schema changed | Proposal no longer matches reviewed contract | Reject and create a new proposal |
| Worker terminated | Last committed checkpoint is authoritative | Resume on another worker with the same run identity |
A Git branch, database transaction, and workflow checkpoint each cover different resources. There is no universal rollback across a repository, email provider, payment system, and deployment API. Design compensating actions per domain, and do not label compensation as proof that the original effect never happened.
Emit Evidence Without Leaking Data
Observability should explain control flow and outcomes without becoming a second sensitive-data store. Emit application-owned events such as:
{
"event": "tool_effect_resolved",
"run_id": "run-7",
"step_id": "step-4",
"call_id": "call-9",
"operation_key": "run-7:call-9:close_ticket:2",
"tool": "close_ticket",
"tool_version": "2",
"policy_version": "policy-3",
"policy_decision": "allow_after_approval",
"effect_status": "committed",
"result_digest": "sha256:...",
"latency_ms": 84,
"redactions": ["ticket_body", "access_token"]
}
Keep this event contract stable, then map it to the OpenTelemetry GenAI conventions supported by your current instrumentation. Those conventions now evolve in a dedicated repository and cover GenAI clients, MCP, spans, metrics, and events; they do not define your business outcome or retention policy.
Do not log hidden chain-of-thought. Store bounded model outputs, tool proposals, policy decisions, state transitions, result references, errors, and business outcomes according to a documented retention schedule.
Test the Implementation Before Release
Implementation tests should prove each control at its enforcement point:
| Test | Required assertion |
|---|---|
| Unknown tool | Denied before dispatch |
| Cross-tenant resource | Denied even when schema is valid |
| Changed arguments after approval | Approval rejected |
| Duplicate message delivery | One semantic external effect |
| Timeout after dispatch | State becomes unknown; no blind retry |
| Worker crash after commit | Reconciliation finds the existing result |
| Stale policy or schema | Old proposal and approval rejected |
| Oversized or poisoned result | Result bounded and treated as untrusted data |
| Step or spend exhaustion | Run terminates outside model control |
| Secret in event payload | Redaction or event rejection occurs |
Run contract tests without a model first. Then add representative end-to-end scenarios, fault injection, shadow replay, and release comparisons from the Agent Harness evaluation guide. A model producing the expected sentence does not prove that authorization, deduplication, or recovery worked.
Frequently Asked Questions
How do you implement an Agent Harness?
Start with a typed proposal, authenticated principal, capability registry, policy decision, durable run state, effect journal, approval envelope, and event contract. Implement the smallest end-to-end path and test each failure boundary. Add model providers, MCP adapters, workflow frameworks, and distributed workers only when the contract already says who owns each decision.
Do you need LangGraph to build an Agent Harness?
No. LangGraph is one implementation option for graph state, checkpoints, interrupts, and resume. A durable workflow engine, queue-driven state machine, or application service can satisfy the same contract. Framework choice does not remove the need for authorization, effect reconciliation, retention, migration, and production tests.
Does MCP authorize Agent tool calls?
No. MCP can authenticate and authorize protocol access when configured, but the application and server still need end-user, tenant, resource, and operation policy. A tool schema says what arguments are structurally accepted; it does not prove that this caller may close this ticket or read this file.
Should approval happen inside the Agent loop?
The loop may request approval, but the decision should come from a trusted service or authenticated reviewer and be persisted outside model-controlled context. Bind it to the exact proposal and revalidate it before execution. Long-running approval should suspend the workflow rather than occupy a worker.
What is the difference between a checkpoint and an Effect Journal?
A checkpoint records workflow progress. An Effect Journal records the status and identity of external operations. If a worker crashes after a remote API commits but before the next checkpoint, the checkpoint alone cannot tell whether the effect happened. Reconcile the journal and downstream system by operation key.
Summary
An Agent Harness becomes production-ready when model proposals cannot bypass identity, policy, approval, bounded execution, durable state, and effect reconciliation. Implement the contracts before selecting a framework, keep MCP behind the policy boundary, and treat every timeout after dispatch as an evidence problem rather than an automatic retry.
Related Resources
- Harness Engineering: Scope and Boundaries
- Agent Harness Architecture
- Agent Harness Evaluation
- MCP Protocol Guide
- Agent Harness glossary
- Human-in-the-Loop glossary
- Agent Runtime glossary