Check what an agent run did
An agent harness records everything the agent did — every tool call, every result, the loaded
plugins, the tokens, the timings. Almost nobody checks that record with anything stronger than a
grep. This guide runs the checker that does: a transcript is normalized into a typed event IR,
a trace specification states expectations over it, and the verdict is three-valued and
evidence-cited.
aep observe trace has three verbs. inspect reports what is in a transcript, check judges it
against a specification, and evidence mints the verdict as a record the engine reads. None of
them starts an agent, calls a model or reaches a network — they read a file and evaluate typed
predicates over it, which is what makes a verdict reproducible on any machine on any day.
The commands below run against a real committed transcript — a Claude Code stream-json session
from the planning-plugin eval — so every output shown here is reproducible from a checkout.
Two transcript formats, one set of arguments. A recorded vendor session is stream-json; a
transcript written by aep drive is a metaharness.event/1 event stream. Every verb here
takes either, and picks the reader from the file's own first line — there is no --format to get
wrong. The report's footer names the adapter that judged the run
(adapter claude-code/stream-json or adapter metaharness/event-stream), so a verdict that
changed because the reader changed is visible as that rather than as a change in the agent's
behaviour. Try it on the committed driven step:
target/debug/aep observe trace check \
--spec conformance/trace/expectations.driven-step.trace.yaml \
--transcript crates/observe/trace-spec/tests/fixtures/metaharness-driven-honest-step.jsonl
See what the transcript contains
B=target/debug/aep
$B observe trace inspect --transcript crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl
transcript sha256:143702946b4bf35d3f764a78bee5bac5f530b775f378911352b7c32ecf09dc30
events 36 total — 6 assistant_text, 2 assistant_thinking, 1 rate_limit, 1 run_outcome, 1 session_start, 1 synthetic_injection, 2 thinking_estimate, 11 tool_call, 11 tool_result
unread 0 event(s) the adapter could not read
requests 19 assistant events, 8 api requests
tool Bash: 4 call(s), 0 error(s), in 1116B, results 2755B
tool Edit: 3 call(s), 0 error(s), in 6875B, results 649B
tool Read: 3 call(s), 0 error(s), in 373B, results 4656B
tool Skill: 1 call(s), 0 error(s), in 414B, results 38B
tools-total 11 call(s), results 8098B into context
repeated 0 identical call group(s)
step 1. Skill (event 5): gen 1486ms, exec 35ms
step 2. Bash (event 10): gen 1290ms, exec 187ms
step 3. Bash (event 13): gen 1088ms, exec 21ms
step 4. Bash (event 15): gen 3205ms, exec 38ms
step 5. Read (event 18): gen 555ms, exec 36ms
step 6. Read (event 20): gen 560ms, exec 6ms
step 7. Read (event 22): gen 305ms, exec 16ms
step 8. Edit (event 25): gen 8742ms, exec 26ms
step 9. Edit (event 27): gen 5968ms, exec 28ms
step 10. Edit (event 29): gen 4482ms, exec 9ms
step 11. Bash (event 32): gen 80ms, exec 13ms
time-split inference 27761ms, tool-exec 415ms across 11 step(s)
The census is computed from the same IR the checker judges: per-tool traffic in both directions
(tool inputs spend output tokens; tool results land in the next request's input), and per
step a gen/exec split — the inference interval that produced each call, and the call's own
execution time — derived from the transcript's recorded timestamps, never measured.
inspect states quantities and has no opinion about any of them, which is why it exits 0 whatever
the census says. An opinion about a quantity belongs in a specification.
Judge it against a specification
A trace-spec/1 document states expectations by kind — behavioural (a skill completed, a tool
was called with matching arguments, one thing happened before another), environmental (exactly
these plugins loaded, auth came from the login), and resource-shaped (turns, tokens, cost,
cache use, per-step timing), each gate or advisory:
- id: consulted-the-skill-before-touching-the-store
statement: the skill was loaded before the CLI was reached for, not afterwards
expect:
order:
first: {tool: Skill}
before: {tool: Bash, args: {command: {contains: "protocol artifact"}}}
- id: created-through-the-cli
statement: artifacts were created with the CLI, not with hand-written frontmatter
expect:
tool.called:
tool: Bash
args: {command: {contains: "protocol artifact new"}}
count: {at_least: 1}
The five matchers, and the one difference that catches people
A matcher applies to one named field. There are five, and no boolean combinators between them — when one row cannot say it, the answer is a second row, not an expression language.
| written | holds when |
|---|---|
{exact: "…"} | the field is that text, character for character |
{contains: "…"} | the text appears anywhere in the field |
{glob: "…"} | the whole field matches, with * for any run of characters and ? for one |
{regex: "…"} | the pattern is found anywhere in the field |
{equals: <bool|integer|string>} | a scalar field is that value, compared type for type — false never matches the string "false" |
More columns: swipe horizontally, or focus the table and use the arrow keys.
glob and regex are not two spellings of one thing, and mistaking one for the other is the way
to write a row that quietly stops checking:
- A glob is anchored at both ends; a regex searches.
{glob: "cargo test"}holds only for a command that is exactlycargo test.{regex: "cargo test"}holds forcargo test --workspacetoo. Anchor a pattern yourself with^and$. *,.,+,|and(swap sides. In a glob*means anything and the rest are literal characters; in a pattern.is any character,|is alternation, and a bare*is not a pattern at all — it is refused withTRACE-SPEC-008before any transcript is read.
Reach for regex when a claim needs alternation or a quantifier — no shell call chained a second
command onto a permitted one is {regex: "(&&|\\|\\||;)\\s*\\w"} and is not expressible as a
glob. Reach for glob for paths, which is what it is good at. A matcher that holds for every text
there is — {contains: ""}, {glob: "*"}, {regex: ".*"} — is refused under text.matches: an
expectation that can only report ok is a check that stopped checking.
The report is one row per expectation and this specification declares forty-three, so the command
below takes the first twelve lines rather than abridging by hand. The rows name
aep-planning:planning and aep-planning:decomposer, and the plugin has been aep-plan since
agentplugins@a2077d2: the transcript was recorded before that rename, so those are the ids that
session was actually offered, and the block below is what the command prints today. The recording is
evidence and is not rewritten, so the specification's own rows keep the recorded ids as well, each
marked # recorded-under-this-name.
$B observe trace check --spec conformance/trace/expectations.trace.yaml \
--transcript crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl | head -12
planning-plugin/eval against transcript sha256:143702946b4b… — 43 ok, 0 gap, 0 unk
The planning plugin behaves as its skill says it does
ok our-plugin-loaded aep 0.1.0 from aep@inline is loaded at event 0
ok nothing-else-loaded exactly aep loaded at event 0
ok billed-to-the-session api_key_source = none at event 0
ok the-run-did-not-ask permission_mode = dontAsk at event 0
ok the-operators-output-style-did-not-leak output_style = default at event 0
ok the-skill-was-offered skill aep-planning:planning is among 17 offered at event 0
ok the-decomposer-loaded agent aep-planning:decomposer is among 7 offered at event 0
ok skill-completed aep-planning:planning completed 1 time(s) with success=true, at least 1 at events 5, 6
ok consulted-the-skill-before-touching-the-store first Skill at 5, first Bash(command ~ "protocol artifact") at 10 at events 5, 10
ok created-through-the-cli Bash(command ~ "protocol artifact new") called 2 time(s), at least 1 at events 13, 15
The rows in between are the resource-shaped ones, each marked ok (adv): an advisory expectation
is evaluated and printed and gates nothing, so a cost bound that drifted with model routing cannot
turn a job red on its own. The last four lines close the report:
$B observe trace check --spec conformance/trace/expectations.trace.yaml \
--transcript crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl | tail -4
ok (adv) served-at-standard-speed usage.speed = standard at event 35
spec sha256:a140611aa53c… adapter claude-code/stream-json
note: this report quotes command strings and file paths read out of the transcript; `--redact` replaces them with digests. Transcript sha256:143702946b4bf35d3f764a78bee5bac5f530b775f378911352b7c32ecf09dc30
conformant: the run satisfies every expectation the specification states (exit 0)
Every verdict cites the transcript event indices behind it. The exit codes carry the same
contract as ess conform: 0 conformant, 1 contradicted, 3 nobody found out — an
event the adapter could not read, or a field this transcript does not carry. Unknown is not
false: "the format moved under us" wakes a different person than "the agent did the wrong
thing", and collapsing the two is how checks rot.
Two flags to know before the report leaves your machine
A transcript holds the prompt, the model's reasoning, the file contents it read and the commands
it ran. A check report quotes all of that, and a report is a thing people paste into pull
requests. --redact replaces every citation with an event index and a digest:
$B observe trace check --spec conformance/trace/expectations.trace.yaml \
--transcript crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl --redact | head -6
planning-plugin/eval against transcript sha256:143702946b4b… — 43 ok, 0 gap, 0 unk
The planning plugin behaves as its skill says it does
ok our-plugin-loaded sha256:85a7a8506642 at event 0
ok nothing-else-loaded sha256:86839ae06868 at event 0
ok billed-to-the-session sha256:bf73c5c38808 at event 0
ok the-run-did-not-ask sha256:a8ea2249bf47 at event 0
It is opt-in rather than the default, and the un-redacted rendering carries the footer above naming what it contains — so pasting one somewhere public is a decision rather than an accident.
--advisory <EXPECTATION_ID> downgrades one named expectation for one run. The row is still
evaluated, still printed, and the report names every id that was downgraded:
$B observe trace check --spec conformance/trace/expectations.trace.yaml \
--transcript crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl \
--advisory billed-to-the-session | grep -E 'billed|downgraded'
ok (adv) billed-to-the-session api_key_source = none at event 0
note: downgraded to advisory on the command line: billed-to-the-session — the specification's digest is the document as authored
It is not a way to skip a check. An id the specification does not declare is a usage error, not a
silent no-op — --advisory not-a-real-id exits 1 with "a downgrade that matched nothing would
relax nothing while looking as though it had" — and the downgrade deliberately does not move
trace_conformance.passed in the record below, because a flag the caller passed must not satisfy
a requirement the protocol asked for.
Mint the verdict as evidence
$B observe trace evidence --spec conformance/trace/expectations.trace.yaml \
--transcript crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl \
--observed-at 2026-08-21 --out evidence.yaml
- kind: trace_conformance
specification: planning-plugin/eval
spec_digest: a140611aa53c55cfe96a8b2267b598889f62124bc4939473ce64d855e7ec3fc8
transcript_digest: 143702946b4bf35d3f764a78bee5bac5f530b775f378911352b7c32ecf09dc30
status: passed
expectations_total: 43
expectations_gapped: 0
expectations_unknown: 0
adapter: claude-code/stream-json (written against 2.1.238)
observed_at: 1787270400000
producer:
producer: verifier
verifier: trace-checker
provenance:
command: protocol trace evidence --spec conformance/trace/expectations.trace.yaml --transcript crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl
inputs:
- conformance/trace/expectations.trace.yaml
- crates/observe/trace-spec/tests/fixtures/plugin-eval-7hTYjT.jsonl
observed_at is required, and it defaults to now — the truth, since the transcript is checked by
this process in this second. --observed-at takes a date or epoch milliseconds and exists for the
one case that needs it: a record committed to a repository has to regenerate byte for byte, and a
record whose only moving field is a clock reading fails every drift check. Pin it and the output
above is reproducible. Pin it into the future and the engine refuses the submission rather than
accepting a claim about a check that has not happened.
The record is a summary, not the report: counts, ids and the digest pair cross the boundary; the
cited transcript rows — prompts, file contents — do not. Its producer is the trace-checker
verifier class, and the record is minted in the same process that ran the check, so an agent's own
claim of conformance never satisfies the kind. aep govern evaluate --evidence evidence.yaml reads
the emitted document directly: trace_conformance is one of the evidence kinds the development
protocol declares, and trace_conformance.** one of its observable fact families
(protocols/adp/1.yaml). A behavioural claim about how an agent worked is now a fact the
protocol can require, with the same standing as a test result or a conformance run.
trace evidence exits 0 even for a run it judged badly. Its exit code answers "was a record
produced?"; the verdict is in the record, and the engine is what decides on it.
The whole loop, in one picture
The drawing is of the mechanism, not a screenshot of any one run — and its last panel is still
labelled soon, because it was drawn before the driver shipped. The driver ships today.
aep drive run and aep drive resume walk command/operator workflows. Model-backed workflows
use metaharness aep drive run and metaharness aep drive resume; aep drive status still reads
their retained run records. The hosts make the engine's calls in order and record what they did.
The driver evaluates no gate itself. A driver that could
evaluate a gate would be a second protocol implementation with none of the conformance suites,
and the first time the two disagreed the one nobody tested would win.
Running one needs a harness and a model, so there is no reproducible command for it on this page.
There is a record instead, and it is not a success story. The first governed run of this
repository's own backlog — W4-1/1, 2026-08-21 — blocked, in establish_verifiers, four
states short of the person it was meant to stop at, on two requirements the engine printed: a
specification artifact still in draft, and test.first_result == failed reading passed. Four
model sessions, 80 hook decisions of which 11 were denials, and 11 permission_denials entries in
the transcripts — one for one, each naming its tool. aep observe trace check decided those four
transcripts, and aep observe trace evidence minted a trace_conformance record from one of them.
What the run found was about the step map, not about the enforcement. The full record, including
what it cost and what broke, is docs/plan/harness-wave-4-governed-dogfood.md § The first run.
Sources
The checker and IR live in crates/observe/trace-domain and crates/observe/trace-spec; the specification
format is published as schemas/generated/trace-spec.schema.json; the worked specification is
conformance/trace/expectations.trace.yaml, whose forty-three expectations are checked
against two committed transcripts by the ordinary test suite. Design and acceptance:
docs/design/transcript-conformance-design-v0.1.md, docs/plan/trace-wave-1-transcript-checker.md.
The driver is crates/drive/aep-driver behind aep drive, its enforcement arm is the plugin's
hooks (/path/to/agentplugins/plugins/aep-plan/README.md § The hooks, and what changed about "no hooks"), and
the governed-run record is docs/plan/harness-wave-4-governed-dogfood.md. For building a driver of
your own against the same engine calls, see Integrate an agent
harness.