send contract: it receives TurnInput and AgentContext, and returns a Turn. This tutorial starts with one message, then adds the capabilities for a complete integration. Each step adds a little code; existing parts are marked // … omitted. Each step ends with the assertions its data supports. Add those assertions to an eval, rerun pnpm exec niceeval exp, then use pnpm exec niceeval show in the terminal or pnpm exec niceeval view in the browser to inspect conversations, usage, tool events, and pending requests.
Five principles run through the tutorial:
- Connect the interface your users already use. The Adapter calls the same endpoint and receives the same shape. Do not create an interface just for Evals or import internal application code to call a function directly. See Connect Your Agent for why.
- Keep service lifecycle outside the Adapter. Start and check the real target with the project’s development command, deployment script, or CI before running an Experiment.
senddoes not spawn services, and evals do not install, start, or replace the system under test. - Limit access to credentials. The target process and Adapter each read only the credentials they need. Keep secrets out of source code, eval workspaces, events, and final replies.
- Hand-write transport only. The URL, authentication, and request body depend on the application.
niceeval/adaptersupplies converters from raw responses to the standard event stream.ctx.sessionsupplies the state APIs needed to continue sessions and resume a paused HITL interaction. - Send runtime feedback through
ctx, not directly to the terminal. Usectx.progress(...)for long steps. Usectx.diagnostic(...)for degradation or error context you need to inspect after a run. Throw when execution cannot continue. Do not callconsole.log/erroror writeprocess.stdout/stderrfrom an Adapter.
Confirm the application interface shape
Before writingsend, identify your application’s response shape. NiceEval does not define a new application protocol. Its built-in converters support the common shapes below; choosing the matching converter saves you from writing event mappings:
The following steps use a Chat Completions-shaped interface. Steps two and four show the replacements for a Responses-shaped interface and streaming interfaces.
Step 1: send one message and receive a reply
First let the eval send a message and receive a reply. This is the starting point for every assertion. The smallestsend does three things: send input.text to the application API, put the reply in a message event, and report the Turn’s status:
progress appears only while running and is not saved. A diagnostic is saved with the Attempt’s results and appears in its details after the run. Throw if the HTTP connection fails or the response cannot be parsed. The Attempt is marked errored, with the error phase agent.run. Use pnpm exec niceeval show @<locator> to read the full error.
This step supports direct value checks on t.reply (t.check(t.reply, includes(...)) or pattern(...)), all Judge conversation material, and model comparison on the Experiment side. ctx.model comes from an Experiment’s model. The Runner passes it through unchanged; the Adapter only forwards it. See Tier for the integration tiers.
It has two obvious limits: every Turn is a new conversation, so a second t.send cannot continue the first; and it cannot see tool calls at all. The next two steps solve one of those limits each.
Step 2: carry earlier messages
The first version ofsend starts a new conversation every turn. A second t.send cannot continue the first. To test a multi-turn conversation, send must remember earlier messages.
The Runner promises one thing for sessions: every send in one session line receives the same ctx.session; a new session line—the Eval’s first Turn or the line after t.newSession()—receives a completely new one.
How you continue a session depends on the application interface shape:
- The client supplies complete history—the server is stateless and receives the full messages list each Turn, as with Chat Completions—→ create an Adapter-private slot with
createSessionSlot<TMsg[]>(), then read and write it withctx.session.get(slot)/set(slot, messages). - The server keeps history—the interface accepts a session ID, such as a Responses-shaped
previous_response_idor an SDK-native session/thread—→ usectx.session.idandctx.session.capture(id).
ctx.session.get(historySlot) returns undefined; ?? [] turns it into empty history. The first turn is simply empty history plus a new message. There is no extra capability to declare on defineAgent: use ctx.session to continue the conversation, or omit it to start a new one each turn.
When the application interface accepts a session ID, history lives on the server and the Adapter records only that ID. Change only these two places inside send:
capture writes only when no ID has been recorded. A backend that repeats an ID—or even changes it due to a fork—cannot overwrite the line currently being continued.
This step supports multi-turn conversations and session isolation through t.newSession(). Write a two-turn eval where the second refers to the first. If the reply loses context, check that the request includes the history or session ID.
Step 3: record usage
An Agent that answers correctly but spends ten times as many tokens should not score the same as a frugal one. To limit usage and cost,send must report usage for each turn in the Turn.usage field, alongside events and status. Report what the application returns; the runner adds it across the session and run. A Chat Completions response includes usage, so copy it over. The rest of send is unchanged from step two:
Usage also has optional fields: cacheReadTokens, cacheCreationTokens, reasoningTokens, requests, and costUSD. Fill each only when the API provides it. If no usage is returned, omit usage entirely; other assertions are unaffected.
Fill costUSD only with actual USD cost returned by the Provider or application. Do not put upstream estimates, model price tables, or local calculations in it.
Budgets and t.maxCost() use a separate value: the runner calculates estimatedCostUSD from model, reported token usage, and the price table. It does so even when costUSD is present. See the defineConfig reference for prices and Control run cost for budgets.
If the model has no price in the table, the runner cannot estimate its cost and t.maxCost() returns unavailable, not zero. If an explicitly configured zero rate produces estimatedCostUSD: 0, that zero is a valid estimate and is compared with the limit.
This step supports t.maxTokens() / t.maxCost() and usage in niceeval show --usage and niceeval view. If show has no usage, check whether the API response includes it and whether send returned it.
Step 4: parse tools into events
To assert which tools the Agent called and in what order,send must report tool calls. The response can contain more than reply text: Chat Completions uses tool_calls to record calls from this turn.
The Adapter’s main job is to translate the API response into the standard event stream: one object for each event, in its actual order, in Turn.events. Each object uses one of the types below. For field values, see the complete one-turn example.
send is exactly the same as step two:
events, status, and even the manually copied usage from step three, so return it directly:
Built-in converters use the response shape, not the application’s business protocol. Write a mapping only for a delta stream whose protocol provides no reducer. The mapping states what each frame does;
deltaStream handles concatenation, pairing, and when to form a complete event.
After normalization, the events you emit determine the assertion families Eval authors can write:
A Chat Completions response does not guarantee a complete process record. An application can finish its tool loop server-side and return only a final answer. Therefore,
turnFromChatCompletion returns no completeness proof. Positive assertions such as calledTool work, while negative assertions such as notCalledTool report incomplete evidence. The Responses protocol requires the output array to record the complete process, so turnFromResponses carries a completeness proof and negative assertions are trustworthy. The difference in trustworthiness comes from the interface contract.
This step supports turn.calledTool(), turn.toolOrder(), turn.maxToolCalls(), and turn.noFailedActions(). For ordering across Turns, use session instead of turn. After the run, inspect tools with pnpm exec niceeval show @<locator> --execution. If a tool is missing, check the response’s tool fields and your mapping.
Step 5: HITL
When an application stops mid-Turn to wait for a person—for tool approval or missing information—both sides ofsend have obligations:
- The pausing Turn returns
status: "waiting"and emits aninput.requestedevent with a stableidfor every pending question.t.check(turn.status, equals("waiting"))andt.requireInputRequest()read them, and answers match by thisid. - The answer Turn reaches the Adapter from an Eval’s
t.respond(...)as another ordinarysendon the same session line with the same state. A human decision arrives structurally throughinput.responses; each entry carriesrequestId,optionId, ortext. See Inputs for the Different Answers for the shapes. The Adapter gives that decision back to the application, then continues fetching the result. For a call a human rejects, set the tooloperation.finishedevent’sstatusto"rejected", not"failed": rejection is a human decision, not a tool failure, sonoFailedActions()does not misfire.
ctx.session too. Declare private state with createSessionSlot<Pending>(), save it with ctx.session.set(...) when waiting, and retrieve it with ctx.session.take(...) at the start of the answering turn. Taking clears the value, so it can be consumed only once.
HITL almost always occurs on a streaming interface because the pause happens in the middle of a stream. This example therefore switches to an application that passes native events through SSE. It exercises every preceding step together; see the complete runnable version in the tier1 example:
Pending, pendingSlot, and the initial take branch. Keep the rest. See HITL for the complete waiting, answering, and resuming flow.
This step supports t.check(turn.status, equals("waiting")), t.requireInputRequest(), and t.respond() / t.respondAll(). Assert a rejected call with calledTool(toolMatch(..., { status: "rejected" })). If t.requireInputRequest() finds no request, check that the waiting turn returned both status: "waiting" and an input.requested event.
Step 6: connect OTel traces
To inspect each turn’s model calls and tool duration in the browser, connect the application’s OTel traces. Standard OTel HTTP server instrumentation is enough. Configure one part once at application startup; pass the other part throughsend on every turn.
Configure the receiver address at application startup, outside send. NiceEval uses the same OTLP receiver address for each run. Fix its port in niceeval.config.ts and point the application’s OTel exporter to it at startup; subsequent eval runs need no changes:
send passes is this Turn’s trace context, not the endpoint. ctx.telemetry.headers is a W3C traceparent header the Runner generates freshly for every Turn. Spread it into the request and the spans the application produces for this Turn attach precisely to this Turn’s trace, without misattribution when Evals run concurrently. Back in the main chat-app, send gains exactly one line:
ctx.telemetry appears only when OTel integration is configured. Spreading undefined when it is not configured is safe, so this line can remain permanently. Spans still arrive without this header, but attribution degrades to time windows and that Agent’s Turns fall back to serial execution. Carrying it is what makes attribution accurate under concurrency.
This step supports a per-turn waterfall in niceeval view, including model calls, tool execution, duration, and tokens. Assertions still read events from the earlier steps; spans only supply the waterfall. For an empty or incomplete waterfall, see OTel integration.
Step 7: pass through Experiment flags for A/B comparison
To compare prompt versions or strategies, expose them as switchable application configuration. The Experiment declaresflags; the runner passes them unchanged to send through ctx.flags on every turn. The Adapter forwards them without interpreting them, and the application selects its variant:
flags in two Experiment files and run the same evals with pnpm exec niceeval exp to compare them. This is Tier 3 integration because the application must expose the switches. See Tier for effort and benefits, and Write experiments for flags, model, attempts, and other fields.
This step supports comparing the same evals across variants. After both runs finish, compare them with pnpm exec niceeval show --experiment <id> --experiment <id> or pnpm exec niceeval view. If the scores are almost identical, first confirm the application actually reads the request’s flags.
Reference: the five t APIs as send sees them
After these seven steps, revisit the five APIs that drive conversations from evals. On the Adapter side, each uses the samesend function with different fields; there is no second method to implement:
Related reading
- Adapter —
sendinputs, outputs, three integration tiers, and capability sources. - Connect Your Agent — minimal integration, parameter passing, and optional capabilities.
- HITL — the complete concept of pausing to wait for a person: handshake timing and both sides’ obligations.
- Drive — how an Eval uses
t.send(),t.newSession(), and HITL. - Assert — the complete assertion vocabulary driven by the standard event stream.