Skip to main content
In NiceEval, an Adapter is the adaptation layer between the runner and the system under test. The Adapter implements the send function: it forwards eval-side actions such as t.send() to your application, then translates the application’s response into NiceEval’s standard Turn. Follow-up reading: Write Send

The adapter

If the system under test speaks the standard OpenAI Chat Completions or Responses protocol, use an official adapter directly. For a homegrown frontend-backend protocol (HTTP, gRPC, WebSocket — anything works), write an Adapter: it knows how to authenticate, how to call your application, and how to translate the response into the standard event stream. One full round trip of t.send() crosses this boundary twice: Full round trip of t.send: the eval calls t.send, the runner assembles TurnInput and ctx, the adapter calls your application and returns a Turn with the standard event stream. Outbound: the driving verbs in the eval (t.send / t.sendFile / t.respond) assemble a TurnInput and the ctx for this conversation line, then call the Adapter’s send once. Inbound: the Adapter translates the application’s raw response into a Turn — where events is an array of event objects ordered by when they happened (real values below). The system under test’s URL, auth, and protocol details are passed in through the Adapter factory’s configuration. defineExperiment receives an already configured agent instance; the Adapter consumes that configuration inside the send closure, and forwards per-turn dynamic values (such as ctx.model, ctx.flags, ctx.telemetry) to the application with each request:

Integration tiers

Grouped by where the Adapter connects and what extra observability data it receives, integration comes in three tiers: Tier 1 is send only (not a single line of application code changes, and the full assertion set is already complete at this tier), Tier 2 is send + OTel (the application sends spans to NiceEval, buying the call waterfall in niceeval view), Tier 3 is application changes + experiment flags (feature A/B). What each tier costs, what it buys, and when to move up: see Tier. The eval-side driving API — t.send(), t.sendFile(), t.newSession(), and HITL’s t.respond() / t.respondAll() — all uniformly call the Adapter’s send. How they converge and how to handle them in send: see Write Send.

The send function

Whether the Adapter connects to an HTTP service or a CLI in a Sandbox, the interface exposed to the runner is exactly the same:
progress is short-lived status during the run and is never persisted. diagnostic is a bounded warning or error saved in an Attempt-owned diagnostics channel. Neither can specify a lifecycle phase or change Turn.status on its own. When the connection fails or the response cannot be parsed, throw: the Runner writes a structured error and forms an errored Verdict in the niceeval.verdict channel. send is the only function you must implement. There are only three types in its signature; take them one at a time.
The setup / teardown here are the Agent’s own business of “how it connects itself” (installing a CLI, writing auth config). Environment setup that varies per experiment (installing a binary specific to one experiment, warming up, carrying state across attempts) does not belong on the Agent — it hangs off the .setup() / .teardown() chain methods of the Sandbox spec in the sandbox field; see Sandbox provider · Lifecycle.

Input: TurnInput

Whether the eval side calls t.send(), t.sendFile(), or t.respond(), it arrives at the Adapter as one ordinary send — the entire difference is in the fields: If your application interface does not accept files, ignore files. files: ignore it if the interface does not accept files; outputSchema: forward it if the application interface accepts a schema (Chat Completions’ response_format, Responses’ text.format); it’s fine if it doesn’t — validation happens in the runner anyway (see the Turn.data rule below).

Inputs for the different answers

On HITL answer turns, the human verdict arrives in structured form via input.responses — the Adapter never needs to parse text to guess which sentence answers which request or whether it counts as approval. Every answer carries a requestId; optionId and text are mutually exclusive: if the answer hits one of the request’s option ids it becomes optionId (the eval side has already validated it exists, so no typo can slip through silently); otherwise the whole sentence lands in text as free text. The four typical shapes:
The Adapter’s corresponding obligations: hand the verdict back to the application by requestId (don’t guess by order); for calls a human rejected, set the tool operation.finished event’s status to "rejected" rather than "failed" — rejection is a human decision, not a tool failure, so noFailedActions() doesn’t misfire and calledTool(..., { status: "rejected" }) can assert it precisely.

Context: AgentContext

The Runner binds a lifecycle scope to the Agent’s setup, each send, and teardown separately. The Adapter only reports progress and diagnostics from the current callback; it cannot pass a phase, color, or output stream. progress is never persisted. A diagnostic is committed as an event in an Attempt-owned diagnostics channel. When it cannot continue, throw: the Runner saves the structured error and forms an errored Verdict in the niceeval.verdict channel. Attempt lifecycle state is only active / completed / abandoned; a Verdict token is not Attempt state. There are no flags to check on ctx. All three tier fields have “pass-through” semantics: model and flags are declared by the experiment and handed over verbatim by the runner — the Adapter’s only job is to forward them to the application with the request, never to interpret them; telemetry only appears when OTel integration is configured, and inside send you only need to spread its headers into the request headers — the receiving endpoint is fixed in defineConfig and pointed at by the application at startup, not passed from here; see OTel Integration. experimentId is a stable identifier derived from the path; the typical use is isolating cross-attempt state per experiment inside a Sandbox’s environment hooks (partitioning cache directory names and sandbox snapshot tags by it); see Sandbox provider · Lifecycle. session is the private state of one conversation line, and NiceEval promises exactly one thing about it: every send on the same conversation line receives the same ctx.session, and a new conversation line (the first turn of an eval, or after t.newSession()) receives a brand-new one. The continuation accessors (id/capture) and private typed slots all live on it. Create each slot once with createSessionSlot<T>(), then call set(slot, value) and take(slot) for a paused HITL scene; take clears the value on read. On a new line, id and every slot are unset.

Return: Turn

events is just a plain JS array: one object per thing that happened this turn, in the order it actually happened. For a turn like “How warm is Beijing today?”, where the agent checks the weather before answering, events looks like this:
There are ten object variants in total (message, operation.*, input.requested… full list in the Events Reference). Assertions read this exact array: t.calledTool("get_weather") counts started tool operations, while t.reply takes the last assistant message. Note that each send returns only this turn’s array — stitching turns into a whole conversation line is the runner’s job, covered in the next section. In most cases you don’t hand-write these objects either: an official converter’s return value is already a complete Turn with events, usage, and status filled in — how to pick a converter is covered in Write Send. data is not a “put whatever” pocket; it has exactly one rule: what goes in it is declared by the eval with a schema on the send; only a declaration produces data, and what you get is the declared type.
The declaration, lowered to JSON Schema, reaches the Adapter via input.outputSchema; the Adapter puts the structured output from the application’s response into data (e.g. JSON.parse(reply.content)). Validation is enforced by the runner: if data doesn’t match the declaration, the turn fails immediately with the diff reported — there is no path where “a wrong object got stuffed in and the assertion silently passed”. Conversely, turns with no output declaration have no data: applications that only return text never touch this field — don’t copy the raw response body into it to fill space.

After send returns: where the four Turn fields go

The moment send returns, the Adapter’s work is done — everything after that is the runner’s job. Each of the four fields has a definite destination: The handle you get from await t.send() is a view onto this one Turn: turn-level assertions (things like draft.parked()) look only at this turn’s events, while t-level assertions look at the whole conversation line’s accumulation. So the Adapter never needs to “cooperate” with any assertion — fill in these four fields correctly, and assertion evaluation, verdict folding, and reporting all happen automatically downstream.

Where capabilities come from: proof by construction, not a questionnaire

The negative-assertion row is the only tiered one: notCalledTool asserts “it did not happen”, which presupposes “the events are complete” — and that only holds when the source itself carries a completeness contract (SDK-native event stream pass-through, AI SDK’s result.steps, Responses’ output). Hand-written mappings carry no proof, so negative assertions warn at runtime that they are untrustworthy instead of silently passing. This also means there is no “declared but can’t deliver” drift: trustworthiness follows where the events came from, not the author’s self-assessment. The precise per-capability obligations are in the Capabilities Reference.
  • Write Send — The hands-on tutorial: from sending one message to a full integration, seven steps, each unlocking a set of assertions.
  • Connect Your Agent — The integration overview: minimal integration, parameter channels, and the incremental map.
  • Drive — The eval-side view: how to use t.send(), t.newSession(), and HITL.
  • Assert — The complete assertion vocabulary driven by the standard event stream.
  • Architecture Overview — The four layers and their boundaries.