Lesson 11: Harness Tuning & Policy Gateways for Domain-Specific Agents

This lesson covers how to improve agent accuracy through harness tuning (without changing model weights), how policy gateways provide deterministic enforcement, and how to design sandbox boundaries for multi-agent systems. We use a financial identity resolution system as our running example, grounded in NemoClaw's architecture.

Primary source: Tuning the Harness, Not the Model: A Nemotron 3 Ultra Playbook

1. What Is "The Harness"?

From the LangChain blog:

"The harness is the scaffolding around a model: system prompts, tool descriptions, and middleware around model/tool calls. Distinct from model weights."

The harness is everything EXCEPT the model weights. You improve accuracy by tuning these components, not by fine-tuning the model.

Harness vs Model vs Runtime

graph TB subgraph HARNESS["THE HARNESS - what you tune"] SP["System Prompts"] TD["Tool Descriptions"] AM["AGENTS.md Rules"] SK["Skills Library"] MW["Middleware"] end subgraph MODEL["THE MODEL - fixed"] LLM["LLM Weights"] end subgraph RUNTIME["THE RUNTIME - OpenShell"] SB["Sandbox"] PE["Policy Engine"] IR["Inference Router"] end SP --> LLM TD --> LLM AM --> SP SK --> SP LLM --> MW MW --> SB SB --> PE LLM --> IR style HARNESS fill:#e0f2fe,stroke:#0ea5e9 style MODEL fill:#fee2e2,stroke:#ef4444 style RUNTIME fill:#d1fae5,stroke:#10b981

Why Tune the Harness Instead of Fine-Tuning?

Approach Fine-Tuning Harness Tuning
Cost High (compute, data prep) Low (text files)
Iteration Speed Days to weeks Minutes
Per-Client Customization Separate model per client Different AGENTS.md per client
Versioning Complex model registry Git commits
Rollback Deploy previous model Revert text file

2. The Three Harness Components

Component 1: AGENTS.md — Client/Domain-Specific Rules

From DeepAgents documentation:

"Configuration file for dcode containing user or project customizations to agent instructions. Located at ~/.deepagents/{agent}/AGENTS.md (user) or {project}/.deepagents/AGENTS.md (project)."

For a financial identity resolution system, the AGENTS.md contains client-specific rules:

# .deepagents/AGENTS.md (for Client A - Cash App)

## Identity Resolution Rules

### Field Extraction
- Customer name: Look in "Remitter Name" field, NOT "Account Holder"
- Bank account: 10 digits for this client (not 12)
- Invoice format: INV-XXXXX-YYYY (always this pattern)

### Matching Priority
1. Bank account + routing number = strongest signal
2. Corporate email domain (not gmail/yahoo/hotmail) = high confidence
3. Invoice reference exact match = high confidence
4. Name fuzzy match alone = low confidence, needs corroboration

### Client-Specific Aliases
- "ABC Corp" also appears as "ABC Corporation" and "A.B.C. Corp"
- Customer uses DBA names — check alias table

### Confidence Thresholds (for this client)
- Bank account exact match: 0.95
- Email + name match: 0.85
- Name fuzzy only (Jaro-Winkler > 0.9): 0.60
- Amount + date only: 0.40

### Escalation Triggers
- Amount exceeds $50,000
- Customer name contains "Trust" or "Estate"
- Multiple potential matches with similar confidence

This is injected into the agent's context. The LLM reads these rules and follows them. It's prompt engineering, but structured and versioned per client.

Component 2: Skills Library — Reusable Prompt-Behavior Modules

From the NemoClaw documentation:

"Skills Library: Prompt-behavior modules (field discovery, tool ordering, grounding)"

Skills are task-specific prompts that the Context Builder assembles:

Skills Library Assembly

graph LR subgraph SKILLS["Skills Library"] S1["field_discovery.md"] S2["exact_matching.md"] S3["fuzzy_matching.md"] S4["confidence_scoring.md"] S5["evidence_citation.md"] end CB["Context Builder"] --> S1 CB --> S2 CB --> S3 CB --> S4 CB --> S5 S1 --> PROMPT["Assembled Prompt"] S2 --> PROMPT S3 --> PROMPT S4 --> PROMPT S5 --> PROMPT style SKILLS fill:#f3e8ff,stroke:#a855f7 style CB fill:#845ef7,color:#fff style PROMPT fill:#4dabf7

Example skill file:

# skills/field_discovery.md

## Purpose
Extract structured fields from unstructured payment documents.

## Process
1. Identify document type (bank statement, remittance, invoice, check)
2. Extract fields based on document type:
   - Customer name (look for variations: payer, remitter, account holder)
   - Bank account number (may be partial, may include routing)
   - Email address (if present)
   - Reference numbers (invoice, PO, customer ref)
   - Amount and date

3. For each extracted field:
   - Quote the exact text from the document
   - Note the location (page, section, field name)
   - Flag uncertainty if ambiguous

## Output Format
Return extracted fields with evidence:
{
  "customer_name": {"value": "...", "source": "Field 'Remitter' on page 1"},
  "bank_account": {"value": "...", "source": "Account field, partial: last 4 digits"},
  ...
}

Key distinction: Skills define HOW to do something. AGENTS.md defines WHAT rules apply for this client.

Component 3: Tool Registry — What the Agent Can Call

From the NemoClaw documentation:

"Tool Registry: Read-only tools available to the planner & subagents (configuration)"

Tool descriptions guide the LLM on WHEN and HOW to use each tool:

@tool
def lookup_customer_by_account(
    account_number: str,
    routing_number: str | None = None
) -> list[CustomerMatch]:
    """
    Exact match on bank account number.

    Use this FIRST when you have a bank account number.
    Returns high-confidence matches if found.

    Args:
        account_number: Full or partial account number (min 4 digits)
        routing_number: Optional routing/sort code for disambiguation

    Returns:
        List of matching customers with confidence scores
    """
    ...

@tool
def fuzzy_match_customer_name(
    name: str,
    threshold: float = 0.8
) -> list[CustomerMatch]:
    """
    Fuzzy match on customer name using Jaro-Winkler similarity.

    Use ONLY when exact matches fail.
    Results below 0.8 similarity should be treated with caution.

    Args:
        name: Customer name to search
        threshold: Minimum similarity score (default 0.8)

    Returns:
        List of potential matches with similarity scores
    """
    ...

3. How the Context Builder Assembles Everything

Context Builder Assembly Flow

graph TB subgraph Configuration_Files["Configuration Files"] AM["AGENTS.md
Client-specific rules"] SK["Skills/*.md
Task modules"] TR["Tool Registry
Available tools"] end subgraph Context_Builder["Context Builder"] CB["Assemble Prompt"] end subgraph Assembled_Context["Assembled Context"] SYS["System Prompt"] RULES["Client Rules"] TOOLS["Tool Descriptions"] DOC["User Document"] end AM --> CB SK --> CB TR --> CB CB --> SYS CB --> RULES CB --> TOOLS CB --> DOC SYS --> LLM["LLM"] RULES --> LLM TOOLS --> LLM DOC --> LLM LLM --> OUT["Structured Output
customer_id, evidence, confidence"] style CB fill:#845ef7 style LLM fill:#ff8787 style OUT fill:#63e6be

The Context Builder creates a prompt like this:

[System] You are an identity resolution agent.

[Skill: field_discovery.md content]
- Extract fields from documents...
- Quote exact text as evidence...

[Skill: matching.md content]
- Use exact match tools FIRST...
- Fall back to fuzzy only when needed...

[Client Rules from AGENTS.md]
- Bank account is 10 digits for this client
- Invoice format: INV-XXXXX-YYYY
- "ABC Corp" aliases: "ABC Corporation", "A.B.C. Corp"
- Confidence threshold: 0.85 for auto-accept

[Tools available]
- lookup_customer_by_account: Use FIRST when you have account number
- fuzzy_match_customer_name: Use ONLY when exact fails

[Document to process]
{actual document content}

Result: The same base model behaves differently per client, per domain, without fine-tuning.

4. Two Types of Rules: Prompt-Level vs Enforcement-Level

This is a critical distinction.

Type A: Prompt-Level Rules (Inside the Harness)

These are in AGENTS.md. The LLM reads them and is ASKED to follow them.

# AGENTS.md
- If confidence is below 0.7, flag for human review
- Always cite evidence from the source document

Problem: The LLM might follow these. Or it might hallucinate. It's advisory.

Type B: Enforcement-Level Rules (Policy Gateway)

These are evaluated AFTER the LLM outputs, deterministically by code (not LLM).

# Policy Gateway (deterministic, no LLM)
def evaluate_policy(result: IdentityMatch) -> Decision:
    if result.confidence >= 0.85:
        return Decision.ACCEPT
    elif result.confidence >= 0.60:
        return Decision.HUMAN_REVIEW
    else:
        return Decision.REJECT

    # Additional rules
    if result.amount > 50000:
        return Decision.HUMAN_REVIEW

    if result.customer_id in watchlist:
        return Decision.REJECT

Key point: The LLM cannot bypass this. It doesn't matter what the LLM says — the Policy Gateway evaluates the structured output and makes the final decision.

Comparison

Aspect Prompt-Level Rules (AGENTS.md) Policy Gateway (Deterministic)
What it does Guides LLM behavior Enforces business rules
Can be bypassed? Yes (hallucination, confusion) No (code execution)
Use case Domain knowledge Compliance requirements
Semantics "Try to do X" "X must happen"
Purpose Improves quality Prevents harm

Harness vs Policy Gateway

graph TB subgraph HARNESS["HARNESS - LLM can read, may not follow"] AM["AGENTS.md Rules"] SK["Skills"] TD["Tool Descriptions"] end subgraph LLM_Processing["LLM Processing"] LLM["LLM
Reads rules, tries to follow"] end subgraph POLICY_GATEWAY["POLICY GATEWAY - LLM cannot bypass"] PG["Deterministic Rules
No LLM involved"] end AM --> LLM SK --> LLM TD --> LLM LLM -->|Structured Output| PG PG --> ACCEPT["ACCEPT"] PG --> REVIEW["HUMAN REVIEW"] PG --> REJECT["REJECT"] style AM fill:#4dabf7 style SK fill:#4dabf7 style TD fill:#4dabf7 style LLM fill:#ff8787 style PG fill:#63e6be

5. Why Both Exist: Defense in Depth

Example scenario:
  1. AGENTS.md says: "If confidence < 0.7, flag for review"
  2. LLM reads this and TRIES to follow it
  3. LLM outputs: {customer_id: 123, confidence: 0.65, needs_review: true}

This is the happy path. But what if the LLM hallucinates confidence: 0.95 when evidence only supports 0.65?

6. Mapping to NemoClaw's Five Protection Layers

NemoClaw provides runtime enforcement. Here's how the harness and policy gateway map to NemoClaw's architecture:

NemoClaw Protection Layers

graph TB subgraph Layer1_Network["Layer 1: Network - OpenShell"] NET["CONNECT Proxy
Binary-scoped egress"] end subgraph Layer2_Filesystem["Layer 2: Filesystem - OpenShell"] FS["Landlock LSM
Path-scoped access"] end subgraph Layer3_Process["Layer 3: Process - OpenShell"] PROC["Capabilities + seccomp
Syscall filtering"] end subgraph Layer4_Gateway["Layer 4: Gateway - OpenShell"] GW["Device Auth + JWT
Control plane"] end subgraph Layer5_Inference["Layer 5: Inference - OpenShell"] INF["Credential Injection
inference.local"] end subgraph Application_Layer["Your Application Layer"] HARNESS["Harness
AGENTS.md, Skills, Tools"] POLICY["Policy Gateway
Business rules"] end HARNESS --> INF INF --> NET NET --> FS FS --> PROC PROC --> GW HARNESS -->|Structured Output| POLICY style HARNESS fill:#4dabf7 style POLICY fill:#63e6be style NET fill:#ffd43b style FS fill:#ffd43b style PROC fill:#ffd43b style GW fill:#ffd43b style INF fill:#ffd43b

What Each Layer Protects Against

Layer Threat Mitigated Example
Network (Proxy) Data exfiltration Agent can only reach your database, not evil.com
Filesystem (Landlock) Credential theft Agent cannot read /etc/passwd or ~/.ssh/
Process (Capabilities) Privilege escalation Agent cannot ptrace other processes
Gateway (Auth) Unauthorized control Admin operations require manual approval
Inference (Routing) API key leakage Agent never sees your OpenAI key
Harness (Prompt) Domain errors Agent follows client-specific rules
Policy Gateway Business logic errors High-value transactions require human review

7. Policy Gateway Implementation Options

Option A: Plain Python

from dataclasses import dataclass
from enum import Enum

class Decision(Enum):
    ACCEPT = "accept"
    REVIEW = "review"
    REJECT = "reject"

@dataclass
class PolicyResult:
    decision: Decision
    reason: str
    escalation_queue: str | None = None

def policy_gateway(match_result: dict, client_rules: dict) -> PolicyResult:
    """Deterministic policy evaluation. No LLM."""

    confidence = match_result.get("confidence", 0)
    amount = match_result.get("amount", 0)
    customer_id = match_result.get("customer_id")

    # Rule 1: Confidence thresholds
    accept_threshold = client_rules.get("accept_threshold", 0.85)
    review_threshold = client_rules.get("review_threshold", 0.60)

    if confidence < review_threshold:
        return PolicyResult(Decision.REJECT, "Confidence below threshold")

    # Rule 2: Amount escalation
    amount_threshold = client_rules.get("amount_threshold", 50000)
    if amount > amount_threshold:
        return PolicyResult(
            Decision.REVIEW,
            f"Amount ${amount} exceeds threshold",
            escalation_queue="high_value"
        )

    # Rule 3: Watchlist
    if customer_id in load_watchlist():
        return PolicyResult(Decision.REJECT, "Watchlist match")

    # Rule 4: Evidence completeness
    if not match_result.get("evidence"):
        return PolicyResult(Decision.REVIEW, "Missing evidence citation")

    # Accept
    if confidence >= accept_threshold:
        return PolicyResult(Decision.ACCEPT, "High confidence match")

    # Review
    return PolicyResult(Decision.REVIEW, "Moderate confidence, needs review")

Option B: OPA (Open Policy Agent) — Same Pattern as OpenShell

OpenShell uses OPA for network/filesystem rules. You can use OPA for business logic too:

# business_policy.rego
package identity_resolution

default decision = "review"

decision = "accept" {
    input.confidence >= 0.85
    input.amount <= 50000
    not watchlist_check(input.customer_id)
}

decision = "reject" {
    input.confidence < 0.50
}

decision = "reject" {
    watchlist_check(input.customer_id)
}

watchlist_check(id) {
    data.watchlist[_] == id
}

The key point: whether Python or OPA, it's deterministic, not LLM-based.

8. Sandbox Boundary Decisions for Multi-Agent Systems

When you have multiple agents, you need to decide: shared sandbox or separate sandboxes?

Decision Framework

Factor Shared Sandbox OK Separate Sandbox Needed
Credential scope Same credentials Different credentials (DB vs CDN vs Cloud)
Blast radius Failure acceptable to share Must contain damage independently
Trust level Same trust boundary Different trust levels
Resource needs Similar (CPU/memory) GPU vs minimal container
Network access Same egress rules Different allowed hosts

Case Study: Identity Resolution Pipeline

Identity Resolution Pipeline Sandbox Split

graph TB subgraph SHARED_SANDBOX["SHARED SANDBOX - same credentials, same trust"] FD["Fields Discovery Agent
LLM-based"] SM["Similarity Match Agent
LLM-based"] AI["Approximate Identity Agent
LLM-based"] DB[("Database
Read-only access")] FD --> DB SM --> DB AI --> DB end subgraph OUTSIDE_SANDBOX["OUTSIDE SANDBOX - deterministic, different trust"] PG["Policy Gateway
No LLM, pure rules"] WL[("Watchlist
Compliance data")] PG --> WL end FD -->|Structured Output| SM SM -->|Structured Output| AI AI -->|Structured Output| PG PG --> ACCEPT["ACCEPT"] PG --> REVIEW["HUMAN REVIEW"] PG --> REJECT["REJECT"] style FD fill:#4dabf7 style SM fill:#4dabf7 style AI fill:#4dabf7 style PG fill:#63e6be

Why this split?

  1. All LLM agents share a sandbox: They all work on the same document, same DB read access, same output format. No reason to isolate them from each other.
  2. Policy Gateway outside sandbox: If an LLM is compromised via prompt injection, it cannot influence the final accept/reject decision. The gate is deterministic.

9. Nested DeepAgents and Policy Inheritance

From DeepAgents documentation:

"The harness includes a task tool enabling the main agent to spawn ephemeral subagents for isolated work."

DeepAgents CAN spawn subagents. The question is: what sandbox do they get?

Policy Inheritance Rules

Key rule: Child can never have MORE permissions than parent. Policies only narrow, never widen.

Policy Inheritance Levels

graph TB subgraph Level0_Orchestrator["Level 0: Orchestrator"] L0["Policy: network=*.internal.com
fs=/workspace"] end subgraph Level1_FieldDiscovery["Level 1: Field Discovery"] L1["Policy: INHERITED and narrower
Effective: network=db.internal.com
fs=/workspace"] end subgraph Level2_NameExtractor["Level 2: Name Extractor"] L2["Policy: INHERITED and even narrower
Effective: network=none
fs=/workspace/output"] end L0 --> L1 L1 --> L2 style L0 fill:#845ef7 style L1 fill:#4dabf7 style L2 fill:#63e6be
# Parent agent with balanced policy
parent = create_deep_agent(
    blueprint="balanced",  # network: [pypi, github, npm]
    ...
)

# Subagent can only be same or stricter
child = parent.spawn_subagent(
    prompt="...",
    policy_override={
        "network": ["pypi.org"],  # OK: subset of parent
        # "network": ["pypi.org", "evil.com"]  # BLOCKED: exceeds parent
    }
)

10. Complete Implementation Flow

Complete Implementation Sequence

sequenceDiagram participant U as User/System participant CB as Context Builder participant LLM as LLM in Sandbox participant DB as Database Tools participant PG as Policy Gateway participant HQ as Human Review Queue U->>CB: Document + Client ID Note over CB: Load client AGENTS.md
Load relevant skills
Load tool descriptions CB->>LLM: Assembled prompt + document loop Tool Calls LLM->>DB: lookup_customer_by_account DB-->>LLM: CustomerMatch list LLM->>DB: fuzzy_match_customer_name DB-->>LLM: CustomerMatch list end LLM-->>PG: customer_id, evidence, confidence Note over PG: Deterministic evaluation
No LLM involved alt confidence >= 0.85 PG-->>U: ACCEPT else 0.60 <= confidence < 0.85 PG-->>HQ: HUMAN REVIEW HQ-->>U: Human decision else confidence < 0.60 PG-->>U: REJECT end

11. Practical Implementation Steps

Step 1: Create Bare-Bone Agents (Pre-Created, Not Spawned)

from deepagents import create_deep_agent

# Create agents at startup (not spawned dynamically)
field_discovery_agent = create_deep_agent(
    model="anthropic:claude-sonnet-4-20250514",
    tools=[],  # No tools, just extraction
    system_prompt=load_skill("field_discovery.md")
)

matching_agent = create_deep_agent(
    model="anthropic:claude-sonnet-4-20250514",
    tools=[
        lookup_customer_by_account,
        lookup_customer_by_email,
        fuzzy_match_customer_name,
    ],
    system_prompt=load_skill("matching.md")
)

Step 2: Build Context Builder

def build_context(client_id: str, document: str) -> str:
    """Context Builder: assemble prompt from components."""

    # Load client-specific rules
    agents_md = load_file(f"clients/{client_id}/AGENTS.md")

    # Load relevant skills
    skills = [
        load_skill("field_discovery.md"),
        load_skill("matching.md"),
        load_skill("confidence_scoring.md"),
    ]

    # Assemble context
    return f"""
{chr(10).join(skills)}

## Client-Specific Rules
{agents_md}

## Document to Process
{document}
"""

Step 3: Add Policy Gateway

(See Section 7 for full implementation)

Step 4: Compose the Pipeline

async def process_identity_resolution(
    client_id: str,
    document: str
) -> dict:
    """Main pipeline: agents → policy gateway."""

    # Load client config
    client_rules = load_client_rules(client_id)

    # Build context with client-specific AGENTS.md
    context = build_context(client_id, document)

    # Agent 1: Field Discovery
    fields = await field_discovery_agent.ainvoke({
        "messages": [{"role": "user", "content": context}]
    })

    # Agent 2: Matching (with extracted fields)
    match_result = await matching_agent.ainvoke({
        "messages": [{"role": "user", "content": f"""
Given these extracted fields:
{fields}

Find the matching customer using the available tools.
Return: customer_id, evidence (with citations), confidence score.
"""}]
    })

    # Policy Gateway: Deterministic decision
    policy_result = policy_gateway(match_result, client_rules)

    return {
        "fields": fields,
        "match": match_result,
        "decision": policy_result.decision.value,
        "reason": policy_result.reason,
        "queue": policy_result.escalation_queue,
    }

Step 5: Later — Wrap with NemoClaw/OpenShell

# blueprint.yaml
name: identity-resolution-agent
version: "1.0"

sandbox:
  type: container
  image: python:3.12-slim

filesystem:
  permissions:
    - operations: ["read"]
      paths: ["clients/*/AGENTS.md", "skills/*.md"]
      mode: allow
    - operations: ["read", "write"]
      paths: ["/workspace/output/**"]
      mode: allow

network:
  allowed_hosts:
    - "database.internal:5432"  # Your single database
  blocked_hosts:
    - "*"  # Block everything else

inference:
  providers:
    - anthropic
  credential_injection: true  # API keys stay on host

The agents stay the same. OpenShell adds:

Knowledge Check

Question 1: What is the primary difference between prompt-level rules (AGENTS.md) and the Policy Gateway?

Correct Answer: B - AGENTS.md rules are read by the LLM, which may or may not follow them. The Policy Gateway is deterministic code that evaluates structured output — the LLM cannot bypass it.

Question 2: According to the harness tuning approach, what do you modify to improve domain-specific accuracy?

Correct Answer: B - The harness is "the scaffolding around a model: system prompts, tool descriptions, and middleware." You improve accuracy by tuning these components, not by fine-tuning model weights.

Question 3: When should multiple agents share a single sandbox vs have separate sandboxes?

Correct Answer: B - Agents can share a sandbox when they operate in the same trust boundary with the same credentials. Use separate sandboxes when agents have different credential needs, trust levels, or when blast radius containment is critical.

Question 4: What happens to policy permissions when a DeepAgent spawns a subagent?

Correct Answer: C - Policy inheritance follows the principle: child can never have MORE permissions than parent. Policies only narrow, never widen. This prevents privilege escalation through subagent spawning.

Question 5: Why should the Policy Gateway be "outside the sandbox" in financial systems?

Correct Answer: B - If an LLM is compromised via prompt injection, it cannot influence the final accept/reject decision when the Policy Gateway is outside the sandbox. The gate is deterministic and not subject to LLM manipulation.

Summary

Key Takeaways