send: it receives TurnInput and AgentContext and returns a Turn. This tutorial starts from sending one message and adds, step by step, the capabilities a complete integration needs. Each step adds only a little code; parts already shown are marked with // …omitted; each step ends by listing the assertions the newly added data supports. After finishing any step, write the corresponding assertions into an eval, rerun npx niceeval exp, and check the multi-turn trajectory, usage, tool events, or pending requests in niceeval view.
Three principles run through the whole page:
- Connect at the interface your frontend already uses. The Adapter calls the same endpoint and receives the same format; do not open a new endpoint for evals, and do not import app internals to call functions directly. For why, see Connect your agent.
- Only hand-write the transport. URL, auth, and request body depend on the app.
niceeval/adapterprovides converters from the raw response to the standard event stream;ctx.sessionprovides the state API needed for session continuation and HITL pause/resume. - Runtime feedback goes through
ctx, never straight to the terminal. Usectx.progress(...)for long steps; usectx.diagnostic(...)for degradations or unusual context you want to review after the run; throw when you cannot continue. Do not callconsole.log/errorfrom an Adapter, and do not write toprocess.stdout/stderr.
Confirm your app’s interface shape
NiceEval does not define a new application protocol. Existing apps usually use one of the protocols below, or a variant of one, and the built-in converters are provided for exactly these response shapes:
The steps below use a Chat Completions-shaped interface. Replacement implementations for a Responses-shaped interface and a streaming interface are given in steps two and four respectively.
Step 1: send a message, get the reply
The minimalsend does exactly three things: send input.text to the app’s interface, put the reply into one message event, and report this turn’s status:
progress is not persisted. A diagnostic is written to an Attempt-owned channel and can be reviewed on an Attempt detail page within a selected Run. If the HTTP connection fails or the response cannot be parsed, throw: the Runner records the error in agent.run and forms an errored Verdict in the niceeval.verdict channel.
This step unlocks: t.reply, t.messageIncludes(), all the conversation material for the Judge, and model comparison on the Experiment side. ctx.model comes from the Experiment’s model; the runner passes it through verbatim and the Adapter only forwards it. For integration tiers, see Tier.
It has two obvious limitations: every turn is a brand-new conversation (a second t.send cannot continue from the first), and tool calls are completely invisible. The next two steps solve one each.
Step 2: continue from earlier messages
The runner promises exactly one thing about sessions: everysend on the same session line gets the same ctx.session; a new session line (the eval’s first turn, or after t.newSession()) gets a brand-new one.
How to continue a session depends on the shape of the app’s interface. The app’s interface follows one of two common patterns, and ctx.session provides a pair of accessors for each:
- Client carries the full history (stateless server; the complete message list is sent every turn: the Chat Completions shape is the typical case) → create an adapter-private slot with
createSessionSlot<TMsg[]>(), then usectx.session.get(slot)/set(slot, messages) - Server keeps the history (the interface takes a session id: the Responses shape’s
previous_response_id, the native sessions / threads of various SDKs) →ctx.session.id+ctx.session.capture(id)
ctx.session.get(historySlot) naturally returns undefined on a new session line, and ?? [] normalizes that to empty history. Nothing needs declaring beyond using the typed session slot: connect it and multi-turn continues; skip it and every turn is a new conversation.
If the app’s interface takes a session id, the history lives on the server and the Adapter only records the id — just two changes inside send:
capture only lands when no id has been recorded yet; a backend re-sending the id (or even changing it due to a fork) will not overwrite the line being continued.
This step unlocks: multi-turn conversations, and t.newSession() session isolation.
Step 3: record usage
An agent that answers correctly but burns ten times the tokens should not get the same score as one that is frugal. Usage isusage, the fourth field on Turn alongside events and status: if the app’s interface returns usage, report it truthfully, and the runner accumulates it turn by turn into the session line and the whole run. Chat Completions-shaped responses come with usage — copy it over; the rest of send is exactly the same as step two:
Usage fields also include the optional cacheReadTokens / cacheCreationTokens, the reasoning token count reasoningTokens, the request count requests, and costUSD — if a gateway returns measured cost, fill that in; it takes precedence over price-table estimation. Each field is only filled when the interface actually reports it; if the interface returns no usage, leave usage out entirely, and other assertions are unaffected. This hand-copying is also transitional: the official converter in the next step fills in usage along with everything else.
This step unlocks: the t.maxTokens() / t.maxCost() scorers (maxCost uses costUSD or the price table in the config), plus usage in reports and niceeval view.
Step 4: parse tools into events
The app’s response contains more than reply text — thetool_calls in a Chat Completions-shaped response record which tools this turn called. The Adapter’s most important job is normalizing the interface’s response into the standard event stream: one object per thing that happened this turn, ordered by actual occurrence in Turn.events, each object one of the ten types below (for the actual field values, the contract page has a complete one-turn example):
send is exactly the same as step two:
events, status, even step three’s hand-copied usage are all in the return value; just return it:
The built-in converters work by response shape, not by assuming a specific application protocol. Only delta streams with no ready-made reducer from the protocol side require you to write a mapping; the mapping only declares which operation each frame corresponds to — concatenation, pairing, and landing timing are handled by
deltaStream.
Once normalized, whichever events you emit determine which family of assertions eval authors can write:
The Chat Completions response does not guarantee a complete process record. The app may finish the tool loop server-side and return only the final answer. Because of this,
turnFromChatCompletion’s return carries no completeness proof: positive assertions like calledTool work, but negative assertions like notCalledTool will flag that the evidence is incomplete. The Responses protocol requires the output array to record the complete process, so turnFromResponses’s return carries a completeness proof and negative assertions are trustworthy. The difference in trustworthiness between the two comes from the interface contract.
This step unlocks: the whole family of tool assertions — t.calledTool(), t.toolOrder(), t.maxToolCalls(), t.noFailedActions(), and more.
Step 5: HITL
When the app stops mid-turn to wait for a human (tool approval, missing information),send has obligations on both sides:
- The pausing turn: return
status: "waiting", and emit oneinput.requestedevent with a stableidper pending question —t.parked()andt.requireInputRequest()read them, and answers are matched by thisid. - The answer turn:
t.respond(...)in the eval reaches the Adapter as just another ordinarysend(still the same session line, the same state); the human verdict arrives in structured form viainput.responses, each entry carryingrequestIdandoptionIdortext(for the shapes, see Inputs for the different answers). The Adapter hands the verdict back to the app first, then continues fetching the result. For a call a human rejected, set the tooloperation.finishedevent’sstatusto"rejected"rather than"failed"— a rejection is a human decision, not a tool failure, sonoFailedActions()does not misfire.
ctx.session: create an adapter-private createSessionSlot<Pending>(), call ctx.session.set(slot, scene) when pausing, and ctx.session.take(slot) at the start of the answer turn to get it back — taking it clears it, a single consumption.
HITL almost always happens on streaming interfaces (pausing mid-stream), so this step’s example switches to an app that passes native events through over SSE — it exercises everything from the earlier steps together (complete runnable version in the tier1 example):
Pending, its session slot, and the opening take branch) — the rest stays the same. For the complete mental model of pausing / answering / resuming, see HITL.
This step unlocks: t.parked(), t.requireInputRequest(), t.respond() / t.respondAll(), and the precise assertion calledTool(..., { status: "rejected" }).
Step 6: hook up OTel traces
If the app is already instrumented (standard OTel HTTP server instrumentation is enough), the integration splits into two halves — one is startup-time configuration, the other lives insend. Telling them apart is telling apart “what never changes” from “what changes every turn”.
The endpoint is startup-time configuration; it is not passed from send. NiceEval’s OTLP receiver address is the same on every run, so it does not go through ctx: pin the receiver port in niceeval.config.ts, point the app’s OTel exporter at that fixed URL on startup, and you never need to touch it again no matter how many eval runs follow:
send passes is this turn’s trace context, not the endpoint. ctx.telemetry.headers is a W3C traceparent header the runner generates fresh every turn — spread it into the request, and the spans your app produces this turn attach precisely to this turn’s trace, with no misattribution when multiple evals run concurrently. Back on the storyline’s chat-app, send gains exactly one line:
ctx.telemetry only appears when OTel integration is configured; spreading an undefined when it isn’t configured is safe, so this line can stay permanently. Spans still arrive without this header, but attribution degrades to time windows and that agent’s turns fall back to running serially — carrying it is what makes attribution accurate under concurrency.
This step unlocks: the call waterfall shown per turn in niceeval view, including model calls, tool execution, duration, and tokens. Assertions still read the events produced by the earlier steps; spans are only used for the waterfall. For receiver configuration and span attribution rules, see OTel integration.
Step 7: pass through the experiment’s flags (A/B comparison)
Once the app exposes variants as switchable configuration, the experiment declaresflags, and the runner hands them to send verbatim via ctx.flags every turn; the Adapter does not interpret their meaning, it only forwards them with the request — the app switches variants based on the parameter:
flags, running the same set of evals via npx niceeval exp separately — that’s an A/B comparison. This is Tier 3 of the three integration tiers (it requires the app to cooperate by exposing switches); for the cost and payoff, see Tier; flags alongside model, attempts, and the rest of the experiment fields are covered in Write experiments.
This step unlocks: score comparison across variants over the same set of evals.
Reference: the five t APIs as send sees them
With the seven steps written, look back at the five driving APIs on the eval side — reachingsend, they are just the same function receiving different fields; there is no second method to implement:
Related reading
- Adapter —
send’s inputs and outputs, the three integration tiers, and where capabilities come from. - Connect your agent — minimal integration, parameter passing, and optional capabilities.
- HITL — the complete concept of pausing to wait for a human: handshake timing and both sides’ obligations.
- Drive — the eval side’s usage of
t.send(),t.newSession(), and HITL. - Assert — the complete assertion vocabulary driven by the standard event stream.