Harness orchestration
Dispatch a coding agent as a typed worker with turn caps and schema-validated output. AForge runs by default; Claude Code, Codex, Gemini CLI, and OpenCode are one field away.
Use a harness as a step inside a regular reasoner. The harness runs a coding agent, gives it tool access, bounds the run with a turn cap, and returns a schema-validated object -- not free-form text.
Nothing to install. By default the worker is AForge, AgentField's native harness, provisioned alongside the af binary by the curl installer, AgentField Desktop, and the official agent Docker images. Export OPENROUTER_API_KEY and the example below runs. Want Claude Code, Codex, Gemini CLI, or OpenCode driving it instead? That is one field -- see Bring your own coding agent.
from pydantic import BaseModel
from agentfield import Agent, HarnessConfig
# No provider, no model -- this agent's harness calls run on AForge.
app = Agent(node_id="migrator", harness_config=HarnessConfig())
class MigrationPlan(BaseModel):
sql_statements: list[str]
rollback_steps: list[str]
risk_assessment: str
@app.reasoner()
async def plan_migration(description: str) -> dict:
# Harness reads the schema, writes SQL, validates against the Pydantic model
result = await app.harness(
f"Analyze the database schema and produce a migration plan for: {description}",
schema=MigrationPlan,
max_turns=20, # turn cap -- AForge stops cleanly when it is hit
)
if result.is_error:
return {"error": result.failure_type, "message": result.error_message}
plan: MigrationPlan = result.parsed
return {
"sql": plan.sql_statements,
"rollback": plan.rollback_steps,
"cost_usd": result.cost_usd,
"turns": result.num_turns,
}
# Swap workers per-call -- Codex for test generation, Gemini for big refactors
@app.reasoner()
async def write_tests(module: str) -> dict:
result = await app.harness(
f"Write a comprehensive test suite for {module}.",
provider="codex",
model="o4-mini",
max_turns=40,
)
return {"output": result.text, "cost_usd": result.cost_usd}
app.run()import { Agent, type HarnessConfig } from "@agentfield/sdk";
import { z } from "zod";
// No provider, no model -- this agent's harness calls run on AForge.
const harnessConfig: HarnessConfig = {};
const app = new Agent({ nodeId: "migrator", harnessConfig });
const MigrationPlan = z.object({
sqlStatements: z.array(z.string()),
rollbackSteps: z.array(z.string()),
riskAssessment: z.string(),
});
app.reasoner("plan_migration", async (ctx) => {
// Harness reads the schema, writes SQL, validates against the Zod model
const result = await app.harness(
`Analyze the database schema and produce a migration plan for: ${ctx.input.description}`,
{
schema: MigrationPlan,
maxTurns: 20, // turn cap -- AForge stops cleanly when it is hit
},
);
if (result.isError) {
return { error: result.errorMessage };
}
const plan = MigrationPlan.parse(result.parsed);
return {
sql: plan.sqlStatements,
rollback: plan.rollbackSteps,
costUsd: result.costUsd,
turns: result.numTurns,
};
});
// Swap workers per-call -- Codex for test generation, Gemini for big refactors
app.reasoner("write_tests", async (ctx) => {
const result = await app.harness(
`Write a comprehensive test suite for ${ctx.input.module}.`,
{ provider: "codex", model: "o4-mini", maxTurns: 40 },
);
return { output: result.text, costUsd: result.costUsd };
});
app.serve();package main
import (
"context"
"fmt"
"log"
"github.com/Agent-Field/agentfield/sdk/go/agent"
"github.com/Agent-Field/agentfield/sdk/go/harness"
)
type MigrationPlan struct {
SQLStatements []string `json:"sql_statements"`
RollbackSteps []string `json:"rollback_steps"`
RiskAssessment string `json:"risk_assessment"`
}
func main() {
a, err := agent.New(agent.Config{
NodeID: "migrator",
Version: "1.0.0",
AgentFieldURL: "http://localhost:8080",
// No Provider, no Model -- this agent's harness calls run on AForge.
HarnessConfig: &agent.HarnessConfig{},
})
if err != nil {
log.Fatal(err)
}
a.RegisterReasoner("plan_migration", func(ctx context.Context, input map[string]any) (any, error) {
description := fmt.Sprintf("%v", input["description"])
// Harness reads the schema, writes SQL, validates against the struct
var plan MigrationPlan
schema, _ := harness.StructToJSONSchema(plan)
result, err := a.Harness(ctx,
"Analyze the database schema and produce a migration plan for: "+description,
schema, &plan,
harness.Options{MaxTurns: 20}, // turn cap -- AForge stops cleanly when it is hit
)
if err != nil {
return nil, err
}
if result.IsError {
return map[string]any{"error": result.ErrorMessage}, nil
}
return map[string]any{
"sql": plan.SQLStatements,
"rollback": plan.RollbackSteps,
"turns": result.NumTurns,
}, nil
})
// Swap workers per-call -- Codex for test generation, Gemini for big refactors
a.RegisterReasoner("write_tests", func(ctx context.Context, input map[string]any) (any, error) {
module := fmt.Sprintf("%v", input["module"])
result, err := a.Harness(ctx,
"Write a comprehensive test suite for "+module+".",
nil, nil,
harness.Options{Provider: "codex", Model: "o4-mini", MaxTurns: 40},
)
if err != nil {
return nil, err
}
return map[string]any{"output": result.Text()}, nil
})
a.Serve(context.Background())
}What this gives you
- A multi-turn harness looks like a regular function call -- input goes in, validated object comes out.
- The default worker ships with
af, so the first run needs no CLI install and no per-worker vendor account -- just oneOPENROUTER_API_KEY. max_turnsbounds the run: on the default AForge worker it becomes--turns, and the agent stops cleanly when the cap is hit. The USD cost capmax_budget_usdis enforced byclaude-codeonly -- on AForge, bound spend withmax_turnsplus AForge's own token budget (AFORGE_EXEC_BUDGET, default 150000 tokens).failure_typedistinguishes timeouts, crashes, schema-validation failures, and API errors.
Bring your own coding agent
The loop above is worker-agnostic. Set provider and the identical call drives Claude Code, Codex,
Gemini CLI, or OpenCode instead -- which is how you orchestrate a fleet of Claude Codes (or mix
workers) from a single AgentField reasoner.
# Same reasoner, different worker -- and claude-code is the provider that enforces max_budget_usd.
plan = await app.harness(
prompt, schema=MigrationPlan, provider="claude-code", max_budget_usd=1.00
)
# Or set it once for the whole agent.
app = Agent(node_id="migrator", harness_config=HarnessConfig(provider="claude-code"))// Same reasoner, different worker -- and claude-code is the provider that enforces maxBudgetUsd.
const plan = await app.harness(prompt, {
schema: MigrationPlan,
provider: "claude-code",
maxBudgetUsd: 1.0,
});
// Or set it once for the whole agent.
const app = new Agent({ nodeId: "migrator", harnessConfig: { provider: "claude-code" } });// Same reasoner, different worker -- and claude-code is the provider that enforces MaxBudgetUSD.
result, err := a.Harness(ctx, prompt, schema, &plan,
harness.Options{Provider: "claude-code", MaxBudgetUSD: 1.0})
// Or set it once for the whole agent.
cfg := agent.Config{HarnessConfig: &agent.HarnessConfig{Provider: "claude-code"}}Each override brings its own install and credential (Claude Code differs per SDK):
provider | Install | Credential |
|---|---|---|
aforge (default) | ships with af -- nothing to do | OPENROUTER_API_KEY |
claude-code | Python: pip install 'agentfield[harness-claude]' · TypeScript: npm install @anthropic-ai/claude-agent-sdk · Go: the claude CLI (npm install -g @anthropic-ai/claude-code) | ANTHROPIC_API_KEY |
codex | npm install -g @openai/codex | OPENAI_API_KEY |
gemini | npm install -g @google/gemini-cli | GEMINI_API_KEY |
opencode | curl -fsSL https://opencode.ai/install | bash | opencode auth login |
AGENTFIELD_HARNESS_PROVIDER shifts the default for a whole process without touching code; an
explicit provider on the config or the call still wins. Verify a worker before you depend on it:
af harness doctor --provider aforge # or claude-code / codex / gemini / opencodeNext
Trigger agents on memory changes
Subscribe a reasoner to a memory key pattern. When any agent writes to a matching key, the reasoner fires.
Multi-step human approval that survives restarts
Pause execution mid-run for a human decision. State persists in PostgreSQL, so a crashed server resumes exactly where it left off.