Writing Custom Reporters
Vally ships built-in reporters that render console progress, markdown, JUnit XML, and JSONL. Custom reporters let you observe the same run lifecycle from your own code — to stream results to a dashboard, archive trajectories, push metrics, or build any other run-aware integration.
Reporters are the fifth plugin seam, alongside executors, graders, eval providers, and lint
reporters. They follow the same loader, registry, and --<kind>-plugin conventions.
The PlanReporter interface
Section titled “The PlanReporter interface”A reporter implements one or more lifecycle methods. Every method except onRunComplete and
onTrialResult is optional, so a minimal reporter can implement just the events it cares about:
interface PlanReporter { onRunStart?(ctx: PlanRunStartContext): Promise<void>; onTrialStart?(item: TrialWorkItem): Promise<void>; onTrialResult(event: { item: TrialWorkItem; result: TrialResult }): Promise<void>; onTrialPhase?(itemId: string, phase: TrialPhase, detail?: TrialPhaseDetail): Promise<void>; onDiagnostics?(diagnostics: ScopedDiagnostic[]): Promise<void>; onEvalComplete?(summary: EvalSummary): Promise<void>; onRunComplete( summary: RunSummary, artifacts: RunArtifacts, status?: RunCompletionStatus, ): Promise<void>;}| Method | When it fires |
|---|---|
onRunStart |
Once, before any trial. Carries run-level metadata (see below). |
onTrialStart |
(Optional) When a trial is admitted to the worker pool. |
onTrialResult |
At most once per planned trial (exactly once on a completed run), with the complete trajectory + grade. |
onTrialPhase |
(Optional) Progress sub-events within a trial (execute, grade, …). |
onDiagnostics |
(Optional) Plan-time / run-level / per-trial warnings outside the result stream. |
onEvalComplete |
(Optional) When one (eval × model × variant) finishes. |
onRunComplete |
Once, after the last trial, with the run summary, artifact paths, and status. |
Lifecycle, ordering, and timing contract
Section titled “Lifecycle, ordering, and timing contract”The CLI guarantees the following ordering for every reporter — built-in or plugin-loaded — except for the terminal-delivery clause in step 3, which is scoped to plugin-loaded reporters:
-
onRunStartfires first. Exactly once, strictly before anyonTrialResult. -
onTrialResultfires at most once per planned trial. Ordering across concurrent trials is not guaranteed — trials complete in pool-finish order, not plan order (the pool runs up to--workerstrials at a time). The stronger guarantee — exactly once per planned trial — holds only when the run completes (status === "completed"); on a failed or cancelled run, trials that never reached a terminal result before shutdown are simply never delivered. However, each individualonTrialResultdelivers a complete, immutable trajectory and grade for that one trial; you never observe a partially-populated trial. -
onRunCompletefires last. Strictly after the lastonTrialResult. On a completed run it fires exactly once for every reporter. On a failed or cancelled run, only reporters loaded via--reporter-pluginare guaranteed a terminalonRunComplete(carrying the abortstatusand a possibly-partial summary). The built-in file reporters (markdown, and JUnit when--junitis set) are also finalized with a direct terminalonRunCompleteon abort so their report files are still written, then immediately sealed from further events. The built-in console reporter is the sole exception: it is intentionally skipped on abort, to avoid rendering a partial summary.
Within a single reporter, the composite serializes callbacks (your reporter advances down its own
queue), so you observe your own events in the order above without needing an internal lock.
The optional callbacks interleave with onTrialResult and always fire after onRunStart (they
never precede it). Every reporter that receives a terminal onRunComplete — every reporter on a
completed run, and on a failed/cancelled run both the plugin-loaded reporters and the finalized
built-in file reporters (see point 3) — sees those optional callbacks strictly before it, closing
the onRunStart … onRunComplete envelope: the file reporters are sealed the instant they are
finalized, so the late onDiagnostics the CLI may emit afterward (e.g. an OTel trace-location
notice) never reaches them. The only reporter that observes such late onDiagnostics with no closing
onRunComplete is the console reporter, which is not finalized on abort.
Run completion status
Section titled “Run completion status”onRunComplete receives an optional trailing status: RunCompletionStatus describing the run’s
terminal lifecycle state, independent of the pass/fail verdict (which lives in summary.passed /
summary.hadExecutionErrors):
status |
Meaning |
|---|---|
"completed" |
Every planned trial ran to its natural end. Read the verdict from summary. |
"failed" |
The run aborted on an unrecoverable error. summary may be partial. |
"cancelled" |
The user interrupted the run (Ctrl+C / SIGTERM). summary may be partial. |
The parameter is optional and trailing, so reporters that only implement
onRunComplete(summary, artifacts) stay valid — treat an absent status as "completed".
Run-level metadata
Section titled “Run-level metadata”onRunStart receives a PlanRunStartContext rich enough to open a run-level container with full
provenance, not just counts:
interface PlanRunStartContext { evals: EvalPlanMetadata[]; // eval names, models, executor names, planned stimulus names + counts, runs totalItems: number; // total planned trials workers: number; // max concurrent in-flight trials source?: { name: string; version: string }; // producer identity (tool + version) graderNames?: string[]; // sorted union of grader names in this run tagFilter?: Record<string, string[]>; // active tag filter, if any}Per-trial payload
Section titled “Per-trial payload”onTrialResult carries the full normalized trajectory plus the grade:
interface TrialResult { itemId: string; // matches TrialWorkItem.id durationMs: number; status: "success" | "error"; trajectory: Trajectory | null; // full event stream; null only if the executor errored first grade: StimulusGradeResult | null; // per-grader breakdown; null with --skip-grade or no graders error?: string;}trajectory.events is the complete normalized event stream (user / assistant messages, tool calls
and results, token usage, reasoning, skill activations, errors, …). grade extends GraderResult,
exposing per-grader name, kind, passed, score, evidence, optional label, nested
details, and structured metadata. The item carries stimulus, model, variant, and
trial-bucketing fields.
Stable identifiers (idempotency / at-least-once delivery)
Section titled “Stable identifiers (idempotency / at-least-once delivery)”Retry-safe consumers need a stable idempotency key per trial. Each trial exposes two:
event.item.id— theTrialWorkItemid, globally unique within a run across(eval × model × variant × stimulus × trial). Always present.event.result.trajectory?.idandevent.result.trajectory?.metadata.sessionID— the executor-level trajectory / session id.trajectoryisnullonly when the executor errored before producing one (result.status === "error"); useitem.idas the fallback key.
Event ordering within a single trajectory (trajectory.events) is deterministic — events
appear in the order the executor emitted them.
Error isolation
Section titled “Error isolation”Every reporter runs inside a composite that isolates failures: a reporter that throws or returns a
rejected promise is logged to stderr and skipped for that one event. A failing reporter never
fails the run and never blocks sibling reporters. All lifecycle methods may be async.
Because those runtime errors are swallowed to preserve isolation, the CLI keeps them visible: each
throw is logged with the reporter’s stable name (for plugin reporters, the --reporter-plugin
specifier — not the unhelpful Object), and at the end of the run a single summary warning reports
how many reporter callback errors occurred. The run’s exit code is unaffected.
A malformed plugin is different from a runtime throw. A reporter that does not implement the
required onTrialResult and onRunComplete methods fails fast at load with a clear error
naming the specifier and the missing method, rather than throwing on every callback during the run.
The optional lifecycle methods (onRunStart, onTrialStart, onTrialPhase, onDiagnostics,
onEvalComplete) may be omitted freely.
Passing the same --reporter-plugin specifier more than once registers it only once: the
duplicate is dropped with a warning so a reporter’s side effects are not silently doubled.
Implementing a reporter
Section titled “Implementing a reporter”Here’s a complete, runnable reporter that collects results in memory and prints a one-line summary at the end:
import type { PlanReporter, PlanRunStartContext, RunArtifacts, RunCompletionStatus, RunSummary, TrialResult, TrialWorkItem,} from "@microsoft/vally";
export class CollectingReporter implements PlanReporter { private readonly trials: Array<{ id: string; passed: boolean }> = [];
async onRunStart(ctx: PlanRunStartContext): Promise<void> { const evalNames = ctx.evals.map((e) => e.evalName).join(", "); console.error(`[collecting] run started: ${evalNames} (${ctx.totalItems} trials)`); }
async onTrialResult(event: { item: TrialWorkItem; result: TrialResult }): Promise<void> { // Use a stable id for idempotent delivery; copy what you need now — // the trajectory may be reclaimed after the run. const id = event.result.trajectory?.metadata.sessionID ?? event.item.id; this.trials.push({ id, passed: event.result.grade?.passed ?? false }); }
async onRunComplete( summary: RunSummary, _artifacts: RunArtifacts, status: RunCompletionStatus = "completed", ): Promise<void> { const passed = this.trials.filter((t) => t.passed).length; console.error(`[collecting] run ${status}: ${passed}/${this.trials.length} trials passed`); }}Shipping as a plugin package
Section titled “Shipping as a plugin package”A reporter plugin exports a registerReporters function that receives the reporter registry and
adds one or more reporters. Reporters are additive — registering several is normal, and there
is no name-conflict check.
-
Create the package
src/index.ts import type { ReporterRegistry } from "@microsoft/vally";import { CollectingReporter } from "./collecting-reporter.js";export function registerReporters(registry: ReporterRegistry): void {registry.register(new CollectingReporter());}Set
@microsoft/vallyas a peer dependency:package.json {"name": "@myorg/vally-reporter-custom","main": "dist/index.js","peerDependencies": {"@microsoft/vally": "^0.6.0"}} -
Use it from the CLI
The
--reporter-pluginflag is repeatable and accepts an npm package name or a local path:Terminal window # npm packagevally eval --reporter-plugin @myorg/vally-reporter-custom --eval-spec eval.yaml# local path (useful during development)vally eval --reporter-plugin ./dist/index.js --eval-spec eval.yaml# multiple reporters fan out in the order listedvally eval --reporter-plugin @myorg/a --reporter-plugin @myorg/b -e eval.yaml# works for experiment runs toovally experiment run experiment.yaml --reporter-plugin @myorg/vally-reporter-custom
Next steps
Section titled “Next steps”- CLI reference: eval —
--reporter-pluginflag - CLI reference: experiment —
--reporter-pluginflag - Writing custom executors — the executor plugin system follows the same pattern
- Trajectory format — the data structure delivered to
onTrialResult