Skip to main content
Integrate an agent harness | AEPThe engine's seven calls, the three rules a harness must get right, and how to persist and replay an execution.AEPhow-toaephow-toadopterdevelopertry-spec-driven-developmentunderstand-safe-agentic-codingrun-agents

Integrate an agent harness

For the person building the thing that runs the agent. The engine does no work of its own: it holds no tools, calls no model, touches no repository. It answers seven questions, and every answer is a function of the validated documents plus the evidence you submitted — never of anything it observed itself.

First: you may not have to build one

AEP supplies the neutral driver, governor and run storage. aep drive executes command and operator steps. Since AEP 0.55.0, model-backed maps run through metaharness aep drive, which imports AEP's library and supplies concrete model execution. A model-backed invocation through aep drive refuses before allocating a run and prints the replacement command.

Install the three local binaries from source checkouts and verify what will be resolved from PATH before crossing a model boundary:

$ cargo install --locked --path crates/edge/aep-cli
$ cargo install --locked --path ../metaharness/crates/metaharness-cli
$ cargo install --locked --path ../harness/crates/harness-cli
$ aep --version
$ metaharness doctor claude
$ metaharness doctor b10x
$ aep drive status

aep drive status is read-only and free. The aep comparison runner in the metaharness checkout goes further: it assembles the fixture, projects the workflow, exercises the confinement governor and stops before any model request unless --spend is present:

$ cargo run --locked --manifest-path ../metaharness/Cargo.toml \
-p metaharness-aep-eval -- native \
--ep-repo "$PWD" --harness-repo ../harness

Its success line is explicit: Everything free has run. No model was started. Paid execution is a different invocation and additionally requires METAHARNESS_LIVE=1, --spend, and an exact budget. See the harness and confinement matrix before choosing an arm.

--plugin-dir names a plugin you supply; this repository ships none and guesses no path under the checkout. The Claude Code and Codex plugins live in the sibling repository beyond10x/agentplugins. Clone it beside this checkout for the directory below, or install it into a harness — in Claude Code, /plugin marketplace add beyond10x/agentplugins then /plugin install aep-plan@beyond10x; in Codex, add the same GitHub repository as a marketplace from the Plugins surface. The agent plugins install page carries the current list.

$ METAHARNESS_LIVE=1 metaharness aep drive run --project . --map development/default \
--plugin-dir /path/to/agentplugins/plugins/aep-plan --pause-on-approval \
--budget-usd 10 --assume-usd-per-run 1
$ aep drive status
$ METAHARNESS_LIVE=1 metaharness aep drive resume AUTH-142/3

This map needs a model and costs money. For a map with an llm step the host needs the explicit environment opt-in METAHARNESS_LIVE=1, an outer --budget-usd, and a conservative --assume-usd-per-run charge that it reserves before every launch. drive status reads the run directory and needs nothing. --map is not optional in this tree: two step maps are written against adp/default/1, so a drive run given neither is refused, naming both ids rather than picking the first as shown by the current command's --help and project map discovery.

It evaluates no gate itself. A driver that could evaluate a gate would be a second protocol implementation with none of the conformance suites behind it, and the first time the two disagreed the one nobody tested would win. Everything it decides, it decides by asking the engine — which is also the argument for reading the rest of this page before writing your own.

Two things the driver does not do, both of which land on you if you build one:

  • The model host selects two harnesses. An llm step says harness: claude-code (the default) or harness: b10x, and Metaharness launches its Claude or b10x adapter. Metaharness also has a Codex adapter, but metaharness aep drive does not yet select it; documenting that distinction prevents a Codex instruction integration from being mistaken for a governed drive arm. Neither supported drive arm is a stranger's harness — see Limitations.
  • It reads a step map, and the two shipped maps verify the two shapes of work this repository has. drivers/development/default.yaml (development/default) names cargo in every state that names a verifier, so a repository whose tests are not Rust tests cannot satisfy test-driven under it. drivers/development/checks.yaml (development/checks) names no compiler and runs one command, bash .engineering/checks/run.sh, so a story whose acceptance is checks somebody can run drives under that one. A repository that is neither writes its own map: the workflow is unchanged, and only the steps under it are yours.

The seven calls

Add aep-engine and aep-domain to your crate; aep-schema if you read tasks and manifests from YAML, and serde_json if you persist executions.

CallAnswersReturns
initialize(task)what is this task held to?Execution, positioned at the workflow's initial state
requirements(&execution)what is owed here, and by which document?Vec<Requirement>
capabilities(&execution)what may be done at all?CapabilityPolicy — allow / approval required / deny
authorize(&mut execution, &request)may this specific action proceed?Decision, with the rule that produced it
submit_evidence(&mut execution, submission)record what a verifier foundEvidenceId, or a rejection if the protocol does not declare that kind
evaluate(&execution)what is permitted now, what is missing, is it finished?Evaluation
transition(&mut execution)moveMoved / Completed / Blocked { reasons }

initialize takes ownership of the task, so nothing can mutate it under an execution already being evaluated. authorize takes &mut because asking is itself an event: the request and its answer both land in the audit trail, including denials.

A minimal loop, compiled as written against the workspace — the one warning is the policy binding, which is there to be read rather than used:

use std::fs;
use std::path::Path;

use aep_domain::action::{Action, ActionRequest, RepositoryWrite};
use aep_domain::evidence::{Evidence, Producer, TestResult, TestSuite};
use aep_domain::time::{ObservedAt, Timestamp};
use aep_domain::verification::Verifier;
use aep_engine::{
audit_trail, load_tree, DecisionExplanation, Engine, EvidenceSubmission, ProtocolEngine,
Registry, TransitionResult,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
// The documents in force. One tree, loaded once, shared by every execution.
let registry: Registry = load_tree(Path::new("."))?;
let engine = Engine::new(registry);

let task = aep_schema::parse::task(&fs::read_to_string("task.yaml")?, None)?;
let artifacts =
aep_schema::parse::artifact_manifest(&fs::read_to_string("artifacts.yaml")?, None)?;
let mut execution = engine.initialize_with_artifacts(task, artifacts)?;

// What is owed here. Each line names the document that asked for it.
for requirement in engine.requirements(&execution) {
println!("{}", requirement.line());
}

// What may be done. Expose exactly these as tools, and nothing else.
let policy = engine.capabilities(&execution);

// Ask before acting, never after.
let request = ActionRequest::new(Action::RepositoryWrite(RepositoryWrite {
paths: vec!["src/auth/passkey.rs".to_owned()],
intent: Some("implement the credential store".to_owned()),
}));
let decision = engine.authorize(&mut execution, &request);
if !decision.is_allowed() {
println!("{}", DecisionExplanation::from(&decision));
return Ok(());
}

// Submit what a verifier produced. The producer is the verifier's, not yours, and
// `observed_at` is when the verifier looked — not when you got round to submitting.
engine.submit_evidence(
&mut execution,
EvidenceSubmission::new(
Evidence::TestResult(TestResult::failing(TestSuite::Unit, 0, 1)),
Producer::Verifier { verifier: Verifier::TestRunner },
ObservedAt::new(Timestamp::from_epoch_millis(1_699_785_600_000)),
),
)?;

match engine.transition(&mut execution)? {
TransitionResult::Moved { from, to, .. } => println!("{from} -> {to}"),
TransitionResult::Completed { state } => println!("complete in {state}"),
TransitionResult::Blocked { state, .. } => {
println!("blocked in {state}");
print!("{}", engine.explain_completion(&execution)); // verbatim, to the user
}
}

for record in audit_trail(&execution) {
println!("{} {}", record.audit_id, record.kind.as_str());
}

let snapshot = execution.snapshot();
fs::write("execution.json", serde_json::to_vec_pretty(&snapshot)?)?;
Ok(())
}

The three rules a harness must get right

1. Never manufacture evidence

The engine will record a TestResult you invented; nothing downstream can tell. That is exactly why this is the harness's responsibility. Three mechanisms help, and none works if you route around them:

  • Report the real producer. Producer::Agent { id } and Producer::Verifier { verifier } are different variants, and independent: true requirements are not satisfied by the first.

  • Say when the verifier looked. EvidenceSubmission::new takes observed_at as its third argument and has no default for it, which is a deliberate refusal: a caller who has to write down when they looked is a caller who cannot accidentally claim they looked just now. The same rule holds at the document boundary — a record with no observation time is not parsed, and one dated ahead of the clock is refused rather than recorded:

    $ aep govern evaluate --task task.yaml --artifacts artifacts.yaml --evidence no-observed-at.yaml
    error: evidence document (no-observed-at.yaml): .[0]: missing field `observed_at` at line 1 column 3 # exit 1

    $ aep govern evaluate --task task.yaml --artifacts artifacts.yaml --evidence dated-2099.yaml
    error: submitting evidence from dated-2099.yaml: the observation time 4070908800000ms is in the
    future; it is 1787352723812ms # exit 1
  • Fill in provenancecommand, tool, revision, workspace, environment, digest, inputs — through EvidenceSubmission::with_provenance, so the record can be re-derived by someone who does not trust you. with_subject says what it is about; stored_as points at the entity a backend holds it as, so the audit trail points at the record rather than at a copy.

Submit evidence as you observe it. Ordering is recorded (evidence.first_seq.<kind>) and rules read it; batching a task's evidence at the end destroys the ordering facts, and the failure looks like a broken rule.

2. Map capabilities onto the tools you actually have

capabilities() returns the policy in force in the current state. Every Action maps to exactly one Capability (Action::required_capability, crates/govern/aep-domain/src/action.rs:252), so authorisation is a lookup:

ActionCapability
RepositoryRead / RepositoryWriterepository.read / repository.write
TestExecutetests.execute
CommandExecute, ToolInvokecommand.execute
NetworkRequest { intent }network.read or network.write, by intent. A request says what it fetches, never who the material was addressed to, so a read asks for the unscoped network.read — the audience wildcard — and a profile granting only network.read:public refuses it. Classify the audience where you can and ask for the scoped form; where you cannot, this is the refusal you want
TelemetryQuerytelemetry.read
Deploy { environment }deployment.create[:environment]
Rollback { environment }deployment.rollback[:environment]
ProductionMutateproduction.write
SecretReadsecret.read
ArtifactWriteartifact.write
ReviewRequestreview.request
ApprovalRequestapproval.request

The mapping is total in one direction only. Four capabilities have no Action that reaches them — artifact.read, production.read, planning.read and planning.write — so a policy can grant or deny them and authorize will never be asked about one. If your harness gates reads, gate them yourself against capabilities(); do not expect a Decision.

Do the mapping once at tool-registration time and check before each call. A tool with no Action to describe it is a tool the protocol cannot govern. To find gaps at plan time rather than mid-task, aep_engine::engine::kinds_for_verifier(&verifier) says which evidence kinds each verifier class can produce — which is how you learn you have no contract runner before the transition that needs one.

3. Route the three truth values to different behaviour

Only True permits a transition; False means observed-and-wrong, Unknown means nobody-observed-it. They want opposite responses:

use aep_domain::predicate::Truth;
use aep_engine::{Engine, Execution, ProtocolEngine};

fn next_step(engine: &Engine, execution: &Execution) -> Vec<String> {
let mut actions = Vec::new();
for requirement in engine.evaluate(execution).completion {
match requirement.outcome.truth {
Truth::True => {}
Truth::Unknown => actions.push(format!("observe: {}", requirement.outcome.requirement)),
Truth::False => actions.push(format!("fix: {}", requirement.outcome.requirement)),
}
}
actions
}

Collapsing Unknown into False produces an agent that tries to fix code nobody has tested; collapsing it into True produces one that finishes tasks nothing verified.

Two layers of enforcement, and what each one cannot see

capabilities() says what may be done. Turning that into a session has two layers, because they fail differently:

LayerSeesBlind to
the tool set the session is launched withwhich tools exist at allevery argument — a Bash tool is a Bash tool whatever the command is
a pre-tool hookthe call's argumentsnothing about workflow state, unless you hand it some

The reference driver runs both. It derives the tool set per state, not per run, from tool_config(&effective_policy(execution)) (crates/drive/aep-driver/src/run.rs:690), and passes the plugin directory into every model session with --plugin-dir (or AEP_DRIVE_PLUGIN_DIR), because a session that never loaded the plugin never loaded the hooks. Every session also carries --strict-mcp-config, so a session's MCP surface is what that line gave it, which is nothing: an account's MCP servers arrive with the login rather than out of a file, and a scratch config directory cannot exclude them. It also writes step-context.json into the run directory, which is how a hook — a separate process, holding no execution — learns which state it is in:

{
"format": "aep.drive-step-context/1",
"state": "establish_verifiers",
"step_index": 0,
"attempt": 1,
"shell_offered": true,
"capabilities": ["repository.read", "repository.write", "tests.execute", "command.execute",
"artifact.read", "artifact.write", "review.request", "approval.request"],
"tools": ["Bash", "Edit", "Glob", "Grep", "NotebookEdit", "Read", "Skill", "Write"],
"reaching": [
"-> implement: guard: test.exists",
"-> implement: ? test.exists — unobserved: test.exists [principle test-driven]",
"-> implement: ? test.first_result == failed — unobserved: test.first_result [principle test-driven]"
]
}

(Two absolute paths — the run directory and the store — are dropped, and the arrays are broken across lines to fit the page; the rest is the file's, except the reaching lines, which are what aep govern evaluate --advance prints under transitions for that transition — the driver and the CLI both read TransitionEvaluation::unmet().) reaching is one line per requirement that does not hold yet on a way out of the state, each prefixed with where that transition goes. What must hold while in it is a different list and is passed separately, because a step given only the second can satisfy every line it was handed and still be refused on the way out.

Every adjudicated call, allow and deny alike, is a tool.decided event in the run's own event stream — the transcript the driver writes. (Until 2026-08-22 this was a separate hook-decisions.jsonl written by plugin shell hooks; on this repository's first governed run it held 80 decisions, 69 allow and 11 deny. The hooks retired under epic:metaharness-migration: their policy is Rust inside the driver, answering the metaharness seam per call, and the side channel no longer exists.) A guard that denies everything audits as little as one that denies nothing, so both halves are the point.

Three limits worth knowing before you copy the shape:

  • The policy denies; it never grants beyond the capability. The narrow fix for "let the model reach one CLI and no other program" would be a scoped capability, and the grammar cannot express it — scoping exists for one thing, an environment on deployment.create and deployment.rollback. So the pattern is a capability grant plus a per-call constraint, and the constraint is pattern-based and best-effort rather than a function of the capability.
  • A decision is in both records, and the two layers can disagree. Since 2026-08-22 the driver calls Engine::authorize at decision time as well: the loop lends the llm step an authorizer over the live execution, a table beside the tool-name renderer turns one call into the ActionRequest it is, and every call reaching the engine lands in the execution's own event stream as action_requested plus action_allowed or action_denied. The order is policy first — it is the only layer that sees arguments, and no ActionRequest tells aep plan artifact list from aep plan artifact list | tee out — and the engine's deny wins over the policy's allow. Two offered tools reach the engine as nothing at all: a skill loader takes no action, and a web search names no URL, so inventing a request for either would record an act nobody performed. What the engine still cannot see is a call the driver's own policy already refused, which is deliberate: a refusal belongs to the layer that took it.
  • The launched tool set is not audited from the transcript. A Claude Code SessionStart event lists the harness's tool inventory, not the session's allow rules. The committed fixture crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl lists thirty-two tools in its init event; the gap register records that it was launched with nine. A transcript check can rule out "the tool did not exist" as an explanation for a refusal; it cannot confirm the allowlist you passed.

Persisting and replaying an execution

Execution::snapshot() returns a serialisable Snapshot: current state, states entered, evidence in submission order, the event stream, the actor. It deliberately does not hold the plan — the plan is re-resolved from the documents on restore, so an execution cannot keep enforcing last month's rules after the documents changed. Engine::restore(task, artifacts, snapshot) refuses a snapshot whose task does not match, and re-observes at the restoring engine's clock, so an evidence horizon is decided against the present rather than against the moment the snapshot was taken.

For replay, construct the engine with Engine::with_clock(registry, FixedClock::new(millis)) — with an injected clock the event stream reproduces exactly, which is what makes an audit trail diffable. SystemClock is the default and is right in production.

The audit trail

aep_engine::trail joins what the protocol decided with what the storage contract records, so a refusal by the protocol and a refusal by a backend land in the same queryable trail:

FunctionUse
audit_trail(&execution)every AuditRecord the event stream implies, in order
decision_record(&execution, &decision, at)one authorisation decision as a record
command_context(&execution, request_id, idempotency_key, at)the CommandContext a command issued during this execution should carry
correlation_id(&execution)the execution id, doubling as the correlation id

command_context takes the request id and idempotency key from you, because only you know whether this is a first attempt or a retry.

When a transition blocks, show the explanation verbatim

CompletionExplanation is the protocol's own account — one line per requirement, each naming the document that asked for it, already written for a person. Summarising it into "some checks failed" throws away the only part that tells anyone what to do. For machine consumers, every explanation serialises; show text to people and JSON to programs, and do not invent a third rendering.

The native route: a projected workflow, walked by the loop

aep drive asks the engine before every step. The b10x harness can also walk a workflow by itself — b10x-harness workflow run takes a flow document and runs one model turn per step, one session per section — and aep govern workflow flow writes that document from a workflow here:

$ aep govern workflow flow --id adp/default --map development/default --out adp.flow.yaml
$ b10x-harness workflow plan --flow adp.flow.yaml # no endpoint: is the shape sound?
$ b10x-harness workflow run --flow adp.flow.yaml --input "AUTH-142" --hooks hooks.json …

Read the projection for what it is. A retreat becomes a group that repeats, terminal states are dropped, and no guard travels: the flow says what runs in what order, and nothing in it can refuse a move. The refusing is done by a transition hook the loop asks before a section is entered and after it leaves — exit 2 at the entry skips the section as failed, exit 2 on a clean exit sends the section back for another attempt, and a hook that cannot answer is read as a refusal. Every state is a section: the projection emits each non-terminal state as a group named for it, holding its steps — one node when the map gave it one step or none — because the loop asks the hook at a group boundary and nowhere else, and a state that were a bare node would be a state the governor is never asked about. A retreat is therefore a section of sections. That hook is where the engine belongs, and metaharness aep drive transition answers it: it reads the loop's JSON on stdin and answers enter from evaluate and leave from transition on a copy of the execution, in the engine's own words. Declare it in the hooks file:

{ "hooks": [ { "on": "transition", "run": ["metaharness", "aep", "drive", "transition", "--run", "AUTH-142/1"] } ] }

--run positions the engine on that run's snapshot over the store as it is now; without it the engine is put on the state the flow path names — the section's first state on enter, its last on leave. A section that came out failed is left alone. The verb decides only: it writes nothing, takes no lock, and a consultation leaves a run's cursor byte-identical. What it does not do is walk: the loop moves the sequencer, and a run that needs the engine to move it is still a metaharness aep drive run for a model-backed map, or aep drive run for command/operator work.

Checking the run afterwards

A harness that reports its own conformance is a harness reporting on itself. aep observe trace check reads the transcript the harness wrote and decides it against a typed specification, so "the agent consulted the CLI before touching the store" becomes a verdict a program produced. aep observe trace evidence mints that verdict as a trace_conformance record the engine accepts, with producer: verifier / trace-checker — see Check what an agent run did.