Skip to content
Build
BuildBuilding Blocks

Harness

Run a coding agent from inside agent loops with turn caps, schema-bound output, and retries. AForge is the default — Claude Code, Codex, Gemini, and OpenCode are one field away.

The building block for driving a coding agent from inside your loop. app.harness(prompt, schema=...) runs AForge, AgentField's native harness, by default — or Claude Code, Codex, Gemini CLI, or OpenCode when you choose them. Either way it waits for structured output, validates it against your schema, and hands back a rich result with cost and retry metadata.

AForge is provisioned alongside the af binary — by the curl installer, AgentField Desktop, and the official agent Docker images — so the default path has nothing to install. Export OPENROUTER_API_KEY and the call below works.

Without harness, you would manage subprocess plumbing, JSON repair, retry-with-backoff, schema validation, output-file cleanup, and per-provider quirks yourself. With harness, those become one call you can wrap in a loop, a tournament, or an adversarial pattern.

The shape

from pydantic import BaseModel
from agentfield import Agent, HarnessConfig

class ReviewResult(BaseModel):
    findings: list[str]
    severity: str  # "low" | "medium" | "high"

# Defaults pinned on the agent — every .harness(...) call inherits them.
# No provider, no model: this runs on AForge with its own default model.
app = Agent(
    node_id="reviewer",
    harness_config=HarnessConfig(
        max_turns=12,
    ),
)

@app.reasoner()
async def review_diff(diff: str) -> dict:
    result = await app.harness(
        f"Review this diff. Be precise, no fluff.\n\n{diff}",
        schema=ReviewResult,
    )

    if result.is_error:
        return {"ok": False, "error": result.error_message}

    return {
        "ok": True,
        "review": result.parsed.model_dump(),
        "cost_usd": result.cost_usd,
        "num_turns": result.num_turns,
        "session_id": result.session_id,
    }
import { Agent, type HarnessConfig } from '@agentfield/sdk';
import { z } from 'zod';

const ReviewResult = z.object({
  findings: z.array(z.string()),
  severity: z.enum(['low', 'medium', 'high']),
});

// No provider, no model: this runs on AForge with its own default model.
const harnessConfig: HarnessConfig = {
  maxTurns: 12,
};

const app = new Agent({ nodeId: 'reviewer', harnessConfig });

app.reasoner<{ diff: string }>('review_diff', async (ctx) => {
  const { diff } = ctx.input;
  const result = await app.harness(
    `Review this diff. Be precise, no fluff.\n\n${diff}`,
    { schema: ReviewResult },
  );

  if (result.isError) {
    return { ok: false, error: result.errorMessage };
  }

  return {
    ok: true,
    review: ReviewResult.parse(result.parsed),
    costUsd: result.costUsd,
    numTurns: result.numTurns,
    sessionId: result.sessionId,
  };
});
package main

import (
    "context"
    "fmt"

    "github.com/Agent-Field/agentfield/sdk/go/agent"
    "github.com/Agent-Field/agentfield/sdk/go/harness"
)

type ReviewResult struct {
    Findings []string `json:"findings"`
    Severity string   `json:"severity"`
}

func newReviewer() (*agent.Agent, error) {
    return agent.New(agent.Config{
        NodeID:  "reviewer",
        Version: "1.0.0",
        // No Provider, no Model: this runs on AForge with its own default model.
        HarnessConfig: &agent.HarnessConfig{
            MaxTurns: 12,
        },
    })
}

func reviewDiff(ctx context.Context, app *agent.Agent, diff string) (*ReviewResult, error) {
    var out ReviewResult
    schema, _ := harness.StructToJSONSchema(out)

    result, err := app.Harness(ctx,
        "Review this diff. Be precise, no fluff.\n\n"+diff,
        schema, &out,
        harness.Options{}, // empty — inherits HarnessConfig from the agent
    )
    if err != nil {
        return nil, err
    }
    if result.IsError {
        return nil, fmt.Errorf("harness: %s", result.ErrorMessage)
    }
    return &out, nil
}

Choose your worker

The default is a starting point, not a lock-in. Set provider and the same call runs on a different coding agent — that is how you orchestrate Claude Code, Codex, Gemini CLI, or OpenCode from inside an AgentField loop, or run a fleet of them side by side.

result = await app.harness(prompt, schema=ReviewResult, provider="claude-code")

# Claude Code is also the provider that enforces a USD cost cap and a tool allowlist.
capped = await app.harness(
    prompt,
    schema=ReviewResult,
    provider="claude-code",
    permission_mode="plan",
    max_budget_usd=0.50,
    tools=["Read", "Grep", "Glob"],
)
const result = await app.harness(prompt, { schema: ReviewResult, provider: 'claude-code' });

// Claude Code is also the provider that enforces a USD cost cap and a tool allowlist.
const capped = await app.harness(prompt, {
  schema: ReviewResult,
  provider: 'claude-code',
  permissionMode: 'plan',
  maxBudgetUsd: 0.5,
  tools: ['Read', 'Grep', 'Glob'],
});
result, err := app.Harness(ctx, prompt, schema, &out,
    harness.Options{Provider: "claude-code"})

// Claude Code is also the provider that enforces a USD cost cap and a tool allowlist.
capped, err := app.Harness(ctx, prompt, schema, &out,
    harness.Options{
        Provider:       "claude-code",
        PermissionMode: "plan",
        MaxBudgetUSD:   0.50,
        Tools:          []string{"Read", "Grep", "Glob"},
    })

The USD cost cap and the tool allowlist are claude-code only — AForge ignores both, so bound a default-provider run with max_turns instead.

Each override needs its own worker installed and its own credential. The default needs no install — only OPENROUTER_API_KEY.

providerInstallCredentialBest at
aforge (default)ships with af — nothing to doOPENROUTER_API_KEYthe zero-setup path; docs
claude-codePython: pip install 'agentfield[harness-claude]' · TypeScript: npm install @anthropic-ai/claude-agent-sdk · Go: npm install -g @anthropic-ai/claude-codeANTHROPIC_API_KEYthe careful reasoner
codexnpm install -g @openai/codexOPENAI_API_KEYthe fast implementer
gemininpm install -g @google/gemini-cliGEMINI_API_KEYthe long-context worker
opencodecurl -fsSL https://opencode.ai/install | bashopencode auth loginthe open-model path

Provider selection resolves in this order, first match wins:

  1. An explicit provider on the call, then on the agent's HarnessConfig.
  2. The AGENTFIELD_HARNESS_PROVIDER environment variable — aforge, claude-code, codex, gemini, or opencode (the Python SDK also accepts grok).
  3. aforge.

HarnessConfig — defaults on the Agent

Pin defaults on the agent constructor; override per call only for the dimensions that genuinely vary by task.

FieldTypeDefaultWhat it does
providerstring"aforge""aforge" / "claude-code" / "codex" / "gemini" / "opencode". Leave unset for AForge; AGENTFIELD_HARNESS_PROVIDER shifts the default.
modelstringemptyEmpty means the provider's own default (AForge uses its default, overridable with AFORGE_MODEL). Set it to pin a provider-specific identifier — e.g. "sonnet", "gpt-5-codex", "gemini-2.5-pro", "qwen/qwen3-coder".
max_turnsint30Hard cap on agent iterations.
max_budget_usdfloatnullUSD cost cap — enforced by claude-code only. AForge and the other CLI providers ignore it (AForge's own --budget is a token budget, not a USD cap); bound those runs with max_turns instead.
max_retriesint3Retry attempts for transient errors (rate-limit, 5xx, connection reset).
initial_delay / max_delay / backoff_factorfloat1.0 / 30.0 / 2.0Exponential-backoff knobs for retries.
toolsstring[]["Read","Write","Edit","Bash","Glob","Grep"]Allowed tool names — claude-code only. AForge and the other CLI providers ignore the list and run with their own toolset.
permission_modestringnull"plan" (plan-first, then execute) or "auto" (bypass per-step prompts). Honoured by claude-code, codex, and gemini; ignored by the default aforge and by opencode.
system_promptstringnullCustom system prompt prepended to the loop.
envdict{}Extra environment variables forwarded to the subprocess.
cwdstringworking dirWorking directory the coding agent treats as the repo root.
project_dirstringnullopencode only — maps to --dir. When set, cwd is used only for output-file placement.
aforge_bin / codex_bin / gemini_bin / opencode_binstringbinary nameOverride CLI paths when the binary is not on $PATH — every provider binary is resolved by a plain $PATH lookup (the Go SDK uses a single BinPath). AForge also reads the AFORGE_BIN env var; af aforge ensure (re)installs it into $AGENTFIELD_HOME/bin (default ~/.agentfield/bin).

HarnessResult — what comes back

FieldTypeWhat it is
result / textstringRaw agent response (last message text).
parsedmodel / nullValidated schema instance when schema= was passed. null if validation fell through all repair layers.
is_error / error_messagebool / stringTrue when the run failed terminally. Inspect error_message for the diagnosis.
cost_usdfloat / nullTotal cost reported by the provider. null when the provider does not surface cost.
num_turnsintIterations the agent took. Useful for cost monitoring and tuning max_turns.
session_idstringProvider session identifier — pass back in resume_session_id to continue a multi-turn run.
messageslistFull message stream (provider-specific shape). Inspect for debugging.

Schema-bound output

Pass a Pydantic model (Python), Zod schema (TypeScript), or Go struct as schema=. The runner injects an OUTPUT REQUIREMENTS suffix telling the agent to write JSON to .agentfield_output.json, then reads, repairs, validates, and returns result.parsed as a validated instance. Three recovery layers run before declaring failure:

  1. Parse the output file directly.
  2. Cosmetic repair — strip markdown fences, trailing commas, repair truncated braces.
  3. One-shot AI repair — re-emit the same content as valid JSON conforming to the schema (no tools, no exploration; cheap reformatting only).

After that, the run is retried up to max_retries times. The output file is cleaned up automatically.

Provider switching is a one-field flip

Every provider is interchangeable through provider=. Same loop code, different worker:

# Plan with Claude (careful reasoner), execute with Codex (fast implementer).
plan = await app.harness(prompt, provider="claude-code", permission_mode="plan", schema=ChangePlan)
edits = await app.harness(apply, provider="codex",       permission_mode="auto", schema=EditReport)
const plan  = await app.harness(prompt, { provider: 'claude-code', permissionMode: 'plan', schema: ChangePlan });
const edits = await app.harness(apply,  { provider: 'codex',       permissionMode: 'auto', schema: EditReport });
plan, _ := app.Harness(ctx, prompt, planSchema, &planOut,
    harness.Options{Provider: "claude-code", PermissionMode: "plan"})
edits, _ := app.Harness(ctx, apply, editSchema, &editOut,
    harness.Options{Provider: "codex", PermissionMode: "auto"})

See the provider docs for the role each one plays best:

Common patterns

Retry with provider fallback

When the primary provider trips a budget or rate-limit, fall back to another worker without losing the loop's intent. Starting from the default keeps the happy path install-free.

async def hardened_harness(prompt: str, schema, **opts):
    for provider in ("aforge", "claude-code"):
        result = await app.harness(prompt, schema=schema, provider=provider, **opts)
        if not result.is_error and result.parsed is not None:
            return result
    raise RuntimeError(f"all providers failed: {result.error_message}")
async function hardenedHarness<T>(prompt: string, schema: T, opts: HarnessOptions = {}) {
  for (const provider of ['aforge', 'claude-code'] as const) {
    const r = await app.harness(prompt, { ...opts, provider, schema });
    if (!r.isError && r.parsed !== null) return r;
  }
  throw new Error('all providers failed');
}
for _, provider := range []string{"aforge", "claude-code"} {
    r, err := app.Harness(ctx, prompt, schema, dest,
        harness.Options{Provider: provider})
    if err == nil && !r.IsError {
        return r, nil
    }
}
return nil, errors.New("all providers failed")

Tournament

Run multiple providers in parallel against the same prompt and pick the best result with an LLM-as-judge call.

runs = await asyncio.gather(*[
    app.harness(prompt, provider=p, schema=ImplResult, max_budget_usd=0.05)
    for p in ("claude-code", "codex", "opencode")
])

verdict = await app.harness(
    "Pick the best implementation. Score on correctness and minimality.\n\n"
    + format_runs(runs),
    provider="claude-code",
    schema=Verdict,
)
return runs[verdict.parsed.winner_index]
const runs = await Promise.all(
  (['claude-code', 'codex', 'opencode'] as const).map((p) =>
    app.harness(prompt, { provider: p, schema: ImplResult, maxBudgetUsd: 0.05 }),
  ),
);
const verdict = await app.harness(
  `Pick the best implementation.\n\n${formatRuns(runs)}`,
  { provider: 'claude-code', schema: Verdict },
);
return runs[verdict.parsed.winnerIndex];
var runs []*harness.Result
for _, p := range []string{"claude-code", "codex", "opencode"} {
    r, _ := app.Harness(ctx, prompt, implSchema, &implOut,
        harness.Options{Provider: p, MaxBudgetUSD: 0.05})
    runs = append(runs, r)
}
v, _ := app.Harness(ctx, judgePrompt(runs), verdictSchema, &verdict,
    harness.Options{Provider: "claude-code"})

Adversarial verifier

One provider proposes a change, a second one verifies it. The verifier's output triggers re-runs until severity drops or the budget caps out.

for attempt in range(3):
    impl = await app.harness(prompt, provider="codex", schema=ImplResult)
    if impl.is_error:
        continue

    review = await app.harness(
        f"Find real bugs in this change:\n\n{impl.parsed.diff}",
        provider="claude-code",
        permission_mode="plan",
        schema=ReviewResult,
    )
    if review.parsed.severity in ("low", "medium"):
        return impl  # accepted

    prompt = f"Re-attempt. Previous reviewer findings:\n{review.parsed.findings}\n\n{prompt}"
for (let attempt = 0; attempt < 3; attempt++) {
  const impl = await app.harness(prompt, { provider: 'codex', schema: ImplResult });
  if (impl.isError) continue;

  const review = await app.harness(
    `Find real bugs in this change:\n\n${impl.parsed.diff}`,
    { provider: 'claude-code', permissionMode: 'plan', schema: ReviewResult },
  );
  if (review.parsed.severity !== 'high') return impl;
  prompt = `Re-attempt. Reviewer findings:\n${review.parsed.findings}\n\n${prompt}`;
}
for attempt := 0; attempt < 3; attempt++ {
    impl, _ := app.Harness(ctx, prompt, implSchema, &implOut,
        harness.Options{Provider: "codex"})
    if impl.IsError { continue }

    review, _ := app.Harness(ctx, reviewPrompt(implOut), reviewSchema, &reviewOut,
        harness.Options{Provider: "claude-code", PermissionMode: "plan"})
    if reviewOut.Severity != "high" { return implOut, nil }

    prompt = retryPrompt(reviewOut, prompt)
}

Authentication

Each provider reads its own credentials from the environment forwarded to the harness subprocess. None of these need to be passed through harness_config.env if they're already in the process environment.

ProviderRequired env vars
aforge (default)OPENROUTER_API_KEY. AFORGE_MODEL optionally overrides the model.
claude-codeANTHROPIC_API_KEY (or Vertex / Bedrock routing via claude_agent_sdk config)
codexOPENAI_API_KEY, or run codex login once for OAuth
geminiGEMINI_API_KEY or GOOGLE_API_KEY, or run gemini once to sign in interactively
opencodeWhatever its opencode auth login flow configures — OPENROUTER_API_KEY, OLLAMA_HOST, etc.

Check what a provider can actually see before you run a loop against it:

af harness doctor --provider aforge     # or claude-code / codex / gemini / opencode

See also