# Eval an AI Agent Application with a Hand-Written Adapter Source: https://niceeval.com/docs/examples/ai-agent-application A runnable AI SDK v6 web agent eval project covering a hand-written adapter, tool calls, image understanding, multi-turn sessions, model comparison, and dual observability. This project shows how to use `defineAgent` to connect an existing HTTP agent to NiceEval. It's a good fit for applications that need control over their own protocol and event mapping. * [View the full source](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/ai-sdk) * Local directory: `examples/zh/ai-sdk/` If your application uses AI SDK v7's UI Message Stream, prefer the built-in `uiMessageStreamAgent` instead. See the corresponding non-intrusive example: [AI SDK v7](/docs/examples/integrations/ai-sdk-v7). ## What this project demonstrates | Part | Implementation | | - | - | | System under test | AI SDK v6 web agent, HTTP `POST /api/turn` | | Adapter | `defineAgent` + custom `AgentEvent` → `StreamEvent` mapping | | Evals | Weather tool, image understanding, multi-turn text, multi-turn image context | | Experiment | Each file pins one model; the group compares DeepSeek against GPT | | Observability | The app keeps reporting to Langfuse while also sending this turn's OTLP data to NiceEval | The adapter is only responsible for connecting and translating. The URL and model belong to the experiment; evals only describe the interaction and what a good result looks like. This separation lets the same evals be reused across different models and deployment instances. ## Directory structure ```text theme={null} examples/zh/ai-sdk/ ├── src/ # web agent under test ├── adapter/adapter.ts # hand-written NiceEval adapter ├── evals/ # tool, image, and multi-turn evals ├── experiments/ # model comparison └── niceeval.config.ts ``` ## Run it This example calls real models — there's no mock mode. Copy the environment variables first and fill in credentials for an OpenAI-compatible service: ```bash theme={null} cd examples/zh/ai-sdk pnpm install cp .env.example .env pnpm dev ``` In a second terminal, run: ```bash theme={null} cd examples/zh/ai-sdk pnpm exec niceeval run list pnpm exec niceeval exp compare-models weather-tool pnpm exec niceeval view ``` ## Next steps * [Connect Your Agent](/docs/tutorials/connect-your-agent): Start from a minimal adapter and connect your own protocol. * [Write Send](/docs/tutorials/write-send): Gradually fill in sessions, tools, HITL, and OTel. * [Write Experiments](/docs/tutorials/write-experiment): Organize model and configuration comparisons. # Eval Coding Agent Extensions Source: https://niceeval.com/docs/examples/coding-agent-extensions Evaluate whether Skills, prompts, and plugin benchmarks improve a coding agent's task results with a real workspace and baseline Experiment. This example treats extension content as an Experiment variable: the same coding agent runs the same batch of real development tasks, and you compare task success rate, cost, latency, and behavior differences. * [View the full source](https://github.com/CorrectRoadH/coding-agent-skill) * [View the Fixtures guide](/docs/tutorials/fixtures) ## Two experiments | Experiment | Comparison | What it tests | | - | - | - | | Zod Skill | `with-skill` vs `baseline` | Whether the Skill makes the agent reliably use `z.object().safeParse()`, instead of falling back to handwritten validation | | Ponytail Benchmark | Baseline / Caveman / Ponytail / YAGNI One-liner | Whether a full decision Skill is more effective than a bare agent, a short-style Skill, or a one-line prompt | The Ponytail group is migrated from a third-party plugin's agentic benchmark, but what this repository actually compares is the content and prompts injected into the agent. It does not prove whether a native plugin wrapper or install protocol is correct. For native Skill/plugin installation and configuration, see [Official Adapters](/docs/reference/official-adapters). ## Experiment design Every Arm holds the same model, Sandbox, task set, Runs, and budget fixed, and only varies the injected content. The eval doesn't read which Arm is currently active, and it doesn't lower its acceptance bar for any experiment group. ```text theme={null} coding-agent-skill/ ├── skills/ # content injected by each Arm ├── workspaces/ts-starter/ # starting project for each eval ├── evals/ # real development tasks and verification ├── experiments/ # baseline and experiment Arms ``` Verification looks at final artifacts first: project tests, hidden probes, source code, and diffs. Only treat Skill load or tool calls as a Gate when the underlying events are stable and complete. ## Run it ```bash theme={null} git clone https://github.com/CorrectRoadH/coding-agent-skill.git cd coding-agent-skill pnpm install cp .env.example .env docker info pnpm exec niceeval exp ponytail-baseline pnpm exec niceeval exp caveman pnpm exec niceeval exp ponytail pnpm exec niceeval exp yagni-oneliner pnpm exec niceeval view --run --run ``` ## What to reuse from this example * Use an Experiment to express the comparison between having the extension and not, without letting the eval know which condition is active. * Keep the prompt from leaking the answer; check tests, source code, diffs, and behavior evidence during verification instead. * Use task success rate as the primary metric, and tokens, cost, latency, and behavior as explanatory metrics. * [Evaluation kinds](/docs/tutorials/evaluation-kinds) owns the choice between Gate, Soft, and Judge; this page doesn't duplicate the assertion API. # NiceEval Examples Source: https://niceeval.com/docs/examples/index Pick a runnable NiceEval example by subject under test: Agent Framework integrations, full AI agent evals, coding-agent extension benchmarks, and real projects. Every example here has real, runnable source. Choose by subject under test — you don't need to read them in order. If you haven't run your first eval yet, read [Quickstart](/docs/tutorials/quickstart) first. If you already know your task, go straight to the matching [Tutorials](/docs/tutorials/connect-your-agent) page. ## Connect an Existing Agent Framework Each of the five examples below keeps both the plain application before integration and the full project after integrating NiceEval. The code diffs on each page are generated from those two sources. | Subject under test | Integration path | Example coverage | | - | - | - | | [AI SDK v7](/docs/examples/integrations/ai-sdk-v7) | Built-in `uiMessageStreamAgent` | UI Message Stream, multi-turn, tools, HITL | | [Claude Agent SDK](/docs/examples/integrations/claude-sdk) | `createClaudeSdkEventStream` | Native event stream, multi-turn, tools, HITL | | [Codex SDK](/docs/examples/integrations/codex-sdk) | `createCodexThreadEventStream` | Coding tasks, files and commands, usage; the SDK doesn't support HITL | | [pi-agent-core](/docs/examples/integrations/pi-sdk) | `createPiAgentEventStream` | Native event stream, multi-turn, tools, HITL; the SDK has no OTel | | [LangGraph](/docs/examples/integrations/langgraph) | Hand-written custom SSE frame mapping | Python application, multi-turn, tools, HITL | Every example's source lives under [`examples/zh/origin/`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin) and [`examples/zh/tier1/`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1). When you need OTel or Experiment Flags, follow the same project into `tier2/` and `tier3/`. Review each run's results locally with `niceeval show`, `query`, or `view`. ## Full Eval Projects [Simulated X social platform](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/llm-x) shows how to evaluate a text and image generation application. Personas, profiles, posts, and social interactions are generated by an LLM; the evals call application methods directly and check state and content relationships. For the integration approach, see [Evaluate a custom LLM application](/docs/tutorials/custom-application). An AI SDK v6 web agent covering tool calls, image understanding, multi-turn sessions, model comparison, and dual observability. Use a real workspace and controlled experiments to measure how Skills, prompts, and plugin benchmarks affect task outcomes. ## Real Projects ### Coding Agent Memory Evals [coding-agent-memory-evals](https://github.com/CorrectRoadH/coding-agent-memory-evals) uses the same batch of development tasks and the same model to compare task success rate, cost, latency, and behavior differences between a coding agent with persistent memory and one without. * [View the source code](https://github.com/CorrectRoadH/coding-agent-memory-evals) ### Terminal-Bench [terminal-bench](https://github.com/NiceEval/terminal-bench) runs the audited Harbor Terminal-Bench task set on NiceEval with the original `run-tests.sh` verifiers, and compares coding agents and models side by side. * [View the source code](https://github.com/NiceEval/terminal-bench) ## Scope of Examples * Single-snippet API usage belongs in a task tutorial or the Reference, not here. * Toy projects without runnable source code are not included. * Roadmap items and integrations that aren't implemented yet are not included. * The same project doesn't get duplicated across multiple case pages just because it's also called a Skill, a Plugin, or a Hook. # Integrate AI SDK v7 with NiceEval non-invasively Source: https://niceeval.com/docs/examples/integrations/ai-sdk-v7 The complete before/after diff for connecting an AI SDK v7 chat app to NiceEval through its HTTP interface: zero app-side code changes. Comparison targets: * **before**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/ai-sdk-v7](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/ai-sdk-v7) -- a regular AI SDK v7 chat app (HTTP server + React chat UI), before any eval integration. * **after**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/ai-sdk-v7](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/ai-sdk-v7) -- the same app after integrating NiceEval. **Integration path**: built-in **`uiMessageStreamAgent`** -- the official non-invasive adapter for the AI SDK UI Message Stream protocol (the standard SSE backend used by `useChat`). The adapter file only declares where the endpoint lives and how to pass `model` in the request body. Session replay, HITL approval (rewriting and resending `needsApproval` tool parts), and tool/message events built directly from protocol frames are all handled by the factory. The protocol frames do not include usage data, so this example does not assert usage. App-side `src/backend/*` files are byte-for-byte unchanged. Use `tier2/` for OTel waterfalls and `tier3/` for feature A/B. All integration code changes (measured from both directories when generated): ## File list ```text theme={null} ai-sdk-v7/ ├── package.json modified ├── tsconfig.json modified ├── pnpm-workspace.yaml modified ├── niceeval.config.ts added ├── agents/ │ └── ai-sdk-v7.ts added ├── evals/ │ ├── basic-qa.eval.ts added │ ├── hitl-approve.eval.ts added │ ├── hitl-deny.eval.ts added │ ├── session-isolation.eval.ts added │ └── weather-tool.eval.ts added └── experiments/ ├── assistant.ts added └── compare-models/ ├── deepseek-v4-flash.ts added └── deepseek-v4-pro.ts added ``` ## App-side changes (dependency declarations only)
## Added adapter, evals, and experiments
# Integrate Claude Agent SDK with NiceEval non-invasively Source: https://niceeval.com/docs/examples/integrations/claude-sdk The complete before/after diff for connecting a Claude Agent SDK assistant backend to NiceEval: zero app-side code changes; everything is added on the eval side. Comparison targets: * **before**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/claude-sdk](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/claude-sdk) -- a standalone `@anthropic-ai/claude-agent-sdk` HTTP service, before any eval integration. * **after**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/claude-sdk](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/claude-sdk) -- the same app after integrating NiceEval. **Integration path**: official converter -- mapping Claude Agent SDK native `SDKMessage` events into standard events is handled by `createClaudeSdkEventStream` (exported from `"niceeval/adapter"`). The adapter only keeps transport glue and HITL stop-turn detection (`calculate` is gated through the official `canUseTool` callback). You start the app yourself in its normal way (`pnpm start`); evals do not manage the process. App-side `src/backend/*` files are byte-for-byte unchanged. All integration code changes (measured from both directories when generated): ## File list ```text theme={null} claude-sdk/ ├── package.json modified ├── tsconfig.json modified ├── pnpm-workspace.yaml modified ├── niceeval.config.ts added ├── agents/ │ └── claude-sdk.ts added ├── evals/ │ ├── basic-qa.eval.ts added │ ├── hitl-approve.eval.ts added │ ├── hitl-deny.eval.ts added │ ├── session-isolation.eval.ts added │ └── weather-tool.eval.ts added └── experiments/ └── assistant.ts added ``` ## App-side changes (dependency declarations only)
## Added adapter, evals, and experiments
# Integrate Codex SDK with NiceEval non-invasively Source: https://niceeval.com/docs/examples/integrations/codex-sdk The complete before/after diff for connecting a Codex SDK coding-agent backend to NiceEval: zero app-side code changes. Comparison targets: * **before**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/codex-sdk](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/codex-sdk) -- a standalone `@openai/codex-sdk` HTTP service, before any eval integration. * **after**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/codex-sdk](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/codex-sdk) -- the same app after integrating NiceEval. **Integration path**: official converter -- mapping Codex native `ThreadEvent` events into standard events is handled by `createCodexThreadEventStream` (exported from `"niceeval/adapter"`). Message text, tool items (`command_execution` / `mcp_tool_call` / `file_change` -> paired `operation.started` / `operation.finished` events), and `turn.completed` usage all come from this stream, leaving only transport glue in the adapter. There is no HITL because Codex SDK does not support it. The evals run real coding tasks (writing files and running commands in a working directory), and assertions read the disk directly. You start the app yourself (`pnpm start`); evals do not manage the process. App-side `src/backend/*` files are byte-for-byte unchanged. Use `tier2/` for OTel waterfalls and `tier3/` for feature A/B. All integration code changes (measured from both directories when generated): ## File list ```text theme={null} codex-sdk/ ├── package.json modified ├── tsconfig.json modified ├── pnpm-workspace.yaml modified ├── niceeval.config.ts added ├── agents/ │ └── codex-sdk.ts added ├── evals/ │ ├── basic-qa.eval.ts added │ ├── create-file.eval.ts added │ ├── run-command.eval.ts added │ └── session-isolation.eval.ts added └── experiments/ └── codex-sdk.ts added ``` ## App-side changes (dependency declarations only)
## Added adapter, evals, and experiments
# Integrate LangGraph with NiceEval non-invasively Source: https://niceeval.com/docs/examples/integrations/langgraph The complete before/after diff for connecting a pure Python LangGraph app with LangSmith OTel export to NiceEval. Comparison targets: * **before**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/langgraph](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/langgraph) -- a pure Python `create_agent` (LangChain 1.x / LangGraph) HTTP service, before any eval integration. * **after**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/langgraph](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/langgraph) -- the same app after integrating NiceEval. **Integration path**: handwritten frame mapping -- custom JSON frames from `server.py` are translated one by one into standard events (`tool-input` -> `operation.started`, `tool-output` -> `operation.finished`, accumulated `text-delta` -> `message`, `tool-approval-request` -> stop turn + `input.requested`). HITL stop-turn state is stored through a typed `ctx.session` slot. The subject app is Python, while the eval side is a separate TypeScript project. App-side `src/backend/*.py` files are byte-for-byte unchanged. Use `tier2/` for OTel waterfalls and `tier3/` for feature A/B. All integration code changes (measured from both directories when generated): ## File list ```text theme={null} langgraph/ ├── package.json added ├── tsconfig.json added ├── pnpm-workspace.yaml added ├── niceeval.config.ts added ├── agents/ │ └── langgraph.ts added ├── evals/ │ ├── basic-qa.eval.ts added │ ├── hitl-approve.eval.ts added │ ├── hitl-deny.eval.ts added │ ├── session-isolation.eval.ts added │ └── weather-tool.eval.ts added └── experiments/ └── langgraph.ts added ``` ## Added TypeScript-side scaffolding (the app itself is unchanged)
## Added adapter, evals, and experiments
# Integrate pi-agent-core with NiceEval non-invasively Source: https://niceeval.com/docs/examples/integrations/pi-sdk The complete before/after diff for connecting a pi-agent-core (@earendil-works) assistant backend to NiceEval: the app side only adds one devDependency. Comparison targets: * **before**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/pi-sdk](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/pi-sdk) -- a standalone `@earendil-works/pi-agent-core` HTTP service, before any eval integration. * **after**: [https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/pi-sdk](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/pi-sdk) -- the same app after integrating NiceEval. **Integration path**: official converter -- mapping pi native `AgentEvent` events into standard events is handled by `createPiAgentEventStream` (exported from `"niceeval/adapter"`). The adapter only keeps transport glue: which URL the app uses and which endpoint handles approvals (`calculate` goes through HITL approval). You start the app yourself in its normal way (`pnpm start`); evals do not manage the process. App-side `src/backend/*` files are byte-for-byte unchanged. All integration code changes (measured from both directories when generated): ## File list ```text theme={null} pi-sdk/ ├── package.json modified ├── tsconfig.json modified ├── pnpm-workspace.yaml modified ├── niceeval.config.ts added ├── agents/ │ └── pi-sdk.ts added ├── evals/ │ ├── basic-qa.eval.ts added │ ├── hitl-approve.eval.ts added │ ├── hitl-deny.eval.ts added │ ├── session-isolation.eval.ts added │ └── weather-tool.eval.ts added └── experiments/ └── assistant.ts added ``` ## App-side changes (dependency declarations only)
## Added adapter, evals, and experiments
# Adapter: connect an Agent to NiceEval Source: https://niceeval.com/docs/explanation/adapter An Adapter is the integration layer you write. This page explains what the send function receives and returns, how to pass the configuration of the system under test, the three integration tiers, and where the capabilities on t come from. In [NiceEval](https://niceeval.com/), an `Adapter` is the integration layer between the Runner and the system under test. An Adapter implements the `send` function: it sends eval-side actions such as `t.send()` to your application, then translates the application's response into [NiceEval](https://niceeval.com/)'s standard `Turn`. Read next: [Write send](/docs/tutorials/write-send) ## Adapters If the system under test uses the standard OpenAI Chat Completions or Responses protocol, use an official Adapter directly. For your own frontend/backend protocol (HTTP, gRPC, or WebSocket all work), 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 complete `t.send()` round trip crosses this boundary twice: On the way out: a drive verb in the eval case (`t.send` / `t.sendFile` / `t.respond`) builds the `TurnInput` and this session line's `ctx`, then calls the Adapter's `send` once. On the way back: the Adapter translates the application's raw response into a `Turn`. Its `events` are an array of event objects in the order they happened (real values appear below). The URL, authentication, and protocol details of the system under test are passed in through the Adapter factory's configuration. `defineExperiment` holds an already configured agent instance. The Adapter consumes this configuration in the `send` closure, then forwards per-turn dynamic values (such as `ctx.model`, `ctx.flags`, and `ctx.telemetry`) to the application with the request: ```ts theme={null} // agents/web-agent.ts import { completeEvidenceCoverage, defineAgent } from "niceeval/adapter"; import type { Agent } from "niceeval/adapter"; interface WebAgentOptions { baseUrl: string; apiKey?: string; } export function webAgent(options: WebAgentOptions): Agent { const baseUrl = options.baseUrl.replace(/\/$/, ""); return defineAgent({ name: "web-agent", evidenceCoverage: completeEvidenceCoverage, async send(input, ctx) { ctx.progress({ message: "Waiting for Web Agent" }); const response = await fetch(`${baseUrl}/api/turn`, { method: "POST", headers: { "content-type": "application/json", ...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {}), ...ctx.telemetry?.headers, }, body: JSON.stringify({ message: input.text, files: input.files, model: ctx.model, flags: ctx.flags, }), signal: ctx.signal, }); if (!response.ok) { ctx.diagnostic({ code: "agent-http-error", level: "error", message: `Web Agent returned HTTP ${response.status}`, data: { status: response.status }, }); return { status: "failed", events: [{ type: "error", message: `HTTP ${response.status}` }], }; } const body = await response.json(); return { status: "completed", events: body.events, data: body.data, usage: body.usage, }; }, }); } // experiments/staging.ts import { defineExperiment } from "niceeval"; import { webAgent } from "../agents/web-agent.ts"; export default defineExperiment({ agent: webAgent({ baseUrl: "https://staging.example.com", apiKey: process.env.STAGING_AGENT_API_KEY, }), model: "gpt-5.4", flags: { promptVariant: "concise" }, }); ``` ## Integration tiers Integration has three tiers based on where the Adapter connects and what extra observability data it gets: **Tier 1 connects only send** (no application code changes, and the full assertion set is already available at this tier), **Tier 2 is send + OTel** (the application sends spans to [NiceEval](https://niceeval.com/) in exchange for the call waterfall in `niceeval view`), and **Tier 3 changes the application and adds experiment flags** (feature A/B). For what each tier costs, what it provides, and when to move up, see [Tier](/docs/explanation/tier). The eval-side drive APIs, `t.send()`, `t.sendFile()`, `t.newSession()`, and the HITL methods `t.respond()` / `t.respondAll()`, all call the Adapter's `send`. For how this converges and how to wire it inside send, see [Write send](/docs/tutorials/write-send). ## The send function Whether the Adapter connects to an HTTP service or a CLI inside a Sandbox, the interface it exposes to the Runner is exactly the same: ```ts theme={null} interface Agent { readonly name: string; send(input: TurnInput, ctx: AgentContext): Promise; // Optional: setup / teardown (the lifecycle that runs once per Sandbox). // Optional observability members do not take part in send; see "OTel integration" } ``` `progress` is short-lived runtime state and is not saved to disk. `diagnostic` is a bounded warning/error that needs review after the run, and it is saved with the Attempt. Neither can specify a lifecycle phase, and neither changes `Turn.status` automatically. Throw an exception when the connection fails or the response cannot be parsed. The Runner records it as `errored` and prints a locator in the terminal, which you can use to see the details of this failure. `send` is the only function you need to implement. Its signature has only three types, covered separately below. The `setup` / `teardown` here are the lifecycle of how the Agent sets up its own connection. A Sandbox Agent can also contribute command-only `.before()` actions through its own `sandbox` field, for example to write the Adapter's public `.env`. The Experiment or eval case selects the one Provider template. For ordering and caching, see [Sandbox · Prepare the Sandbox in a deterministic order](/docs/tutorials/sandbox-providers#prepare-the-sandbox-in-order). ### Input: `TurnInput` ```ts theme={null} interface TurnInput { readonly text: string; // Present on every turn: the text sent. On a response turn = the answer text (multiple joined with newlines) readonly files?: readonly InputFile[]; // Only on t.sendFile turns: attached files (base64) readonly responses?: readonly InputResponse[]; // Only on response turns (t.respond / t.respondAll): one structured answer per request readonly outputSchema?: JsonSchema; // Only when this turn declared output: the structured-output requirement, lowered to JSON Schema } interface InputFile { readonly filename?: string; // Optional, for the adapter / model to refer to readonly mimeType: string; // For example, image/png readonly dataBase64: string; // Base64 content, JSON-friendly, can go straight into a request body } interface InputResponse { readonly requestId: string; // Required: which input.requested request this answers; aligns answers when several requests pause together readonly optionId?: string; // Either this or text: the answer matched an id in the request's options (approve / deny…) readonly text?: string; // Either this or optionId: a free-text answer (the request has no options, or the answer is not any option) } ``` Whether the eval side calls `t.send()`, `t.sendFile()`, or `t.respond()`, the Adapter receives one ordinary `send`. The difference is all in the fields: If your application interface does not accept files, ignore `files`. | Eval side | `text` | `files` | `responses` | `outputSchema` | | - | - | - | - | - | | `t.send(text)` | The sent text | — | — | Present only when `output` was declared | | `t.sendFile(path, text?)` | A note (can be an empty string) | Present | — | Same as above | | `t.respond(...)` / `t.respondAll(...)` | Answer text | — | One per request | Same as above | Ignore `files` if the interface does not accept files. Forward `outputSchema` if the application interface accepts a schema (Chat Completions `response_format`, Responses `text.format`). It is fine if it does not: validation happens in the Runner anyway (see the `Turn.data` rules below). #### Inputs for the different answers In a HITL response turn, a person's decision arrives in structured form with `input.responses`. The Adapter does not need to parse `text` to guess which answer belongs to which request, or whether it counts as approval. Every answer carries `requestId`. `optionId` and `text` are mutually exclusive: when the answer matches an id in the request's `options`, it is `optionId` (the eval side has already checked that it exists, so a typo cannot slip through silently). Otherwise the whole answer is free text in `text`. The four typical forms: ```ts theme={null} // ① One pending request, the answer matches an option (approve / deny are the same, only optionId differs) await t.respond("approve"); // → { text: "approve", // responses: [{ requestId: "req_1", optionId: "approve" }] } // ② Several requests pause together; the object form names the request explicitly await t.respond({ request, optionId: "deny" }); // → { text: "deny", // responses: [{ requestId: request.id, optionId: "deny" }] } // ③ The answer is not any option → free text (requests waiting for more information look like this) await t.respond("Change the recipient to ceo@corp.com"); // → { text: "Change the recipient to ceo@corp.com", // responses: [{ requestId: "req_1", text: "Change the recipient to ceo@corp.com" }] } // ④ respondAll: one answer per pending request, all with the same optionId await t.respondAll("approve"); // → { text: "approve\napprove", // responses: [{ requestId: "req_1", optionId: "approve" }, // { requestId: "req_2", optionId: "approve" }] } ``` On the Adapter side, hand each decision back to the application by `requestId`; do not guess by order. A call rejected by a person sets the tool `operation.finished` `status` to `"rejected"`, not `"failed"`. Rejection is a human decision, not a tool failure. This way `noFailedActions()` is not tripped by mistake, and `calledTool(toolMatch(..., { status: "rejected" }))` can assert it precisely. ### Context: `AgentContext` ```ts theme={null} interface AgentContext { // Feedback and runtime context present on every turn readonly signal: AbortSignal; // Runner timeout and cancellation: pass it to every request you make readonly session: AgentSession; // State slots for this session line progress(update: { message: string; current?: number; total?: number }): void; diagnostic(input: { code: string; level: "warning" | "error"; message: string; data?: Readonly>; dedupeKey?: string; }): void; // Three fields that appear by integration tier (see "Integration tiers") readonly model?: string; // Tier 1 model comparison: experiment.model passed through. Forward it if the application interface accepts model selection readonly reasoningEffort?: string; // Reasoning effort: experiment.reasoningEffort passed through, owned the same way as model readonly telemetry?: Telemetry; // Tier 2: this turn's W3C trace context (headers, a new traceparent each turn) readonly flags: Readonly>; // Tier 3 feature A/B: experiment.flags passed through. {} when not configured readonly experimentId?: string; // Experiment id derived from the path, same source as experimentId in results. undefined when not run through an experiment } interface SandboxAgentContext extends AgentContext { readonly sandbox: Sandbox; // Available only to Agents built with defineSandboxAgent } interface AgentSession { // Session continuation: the server keeps history (the shape for interfaces that accept a session id) readonly id?: string; // The session id recorded on this line. undefined on a new session line capture(id: string | undefined): void; // Record a returned id. Only takes effect if none was recorded yet // Adapter-private state: client-side history, a paused HITL turn, and so on get(slot: SessionSlot): T | undefined; set(slot: SessionSlot, value: T): void; take(slot: SessionSlot): T | undefined; // Clears on read, consumed once } ``` The Runner binds a separate lifecycle scope for the Agent's `setup`, each `send`, and `teardown`. The Adapter only reports progress/diagnostic from inside the current callback, and cannot pass a phase, color, or output stream. `progress` is not saved to disk. `diagnostic` is committed to Record as an Attempt-owned diagnostic-channel event. When it cannot continue, throw; the Runner saves a structured error and forms an `errored` verdict on the `niceeval.verdict` channel. The Attempt lifecycle state is only `active` / `completed` / `abandoned`; a verdict token is not an Attempt state. `ctx` has no flags for you to check. The three tier fields are all pass-through: `model` and `flags` are declared by the Experiment and handed over unchanged by the Runner. The Adapter only forwards them to the application with the request and does not interpret their meaning. `telemetry` appears only when OTel integration is configured; in `send`, just spread `headers` into the request headers. The receiving endpoint is fixed in `defineConfig` and pointed to when the application starts, not passed from here. See [OTel integration](/docs/tutorials/connect-otel). `experimentId` is a stable identifier derived from the path. You can use it in dynamic Sandbox callbacks to isolate cross-Attempt state per experiment. See [Sandbox · Prepare the Sandbox in a deterministic order](/docs/tutorials/sandbox-providers#prepare-the-sandbox-in-order). `session` is the state owned by this session line. [NiceEval](https://niceeval.com/) promises only one thing about it: **every `send` on the same session line gets the same `ctx.session`, and a new session line (the eval case's first turn, or after `t.newSession()`) gets a brand new one.** Continue server-side history with `id` / `capture`. For client-side history and paused HITL turns, the Adapter creates a typed slot at module scope with `createSessionSlot(name)`, then reads and writes it with `get` / `set` / `take`. `take` clears the value as it reads it. Slots are isolated by symbol identity, so they never collide even with the same name. ### Return: `Turn` ```ts theme={null} interface Turn { readonly events: StreamEvent[]; // ★ The standard event stream: every Adapter's core output readonly data?: JsonValue; // This turn's structured output. Fill it only when input.outputSchema is present; the Runner validates it against the declaration readonly status: "completed" | "failed" | "waiting"; // waiting = the turn paused for a person (HITL) readonly usage?: Usage; } ``` `events` is an **ordinary JS array**: one object for each thing that happened in this turn, in the real order it happened. For example, in a turn asking "What's the temperature in Beijing today?", the agent looks up the weather and then answers, and `events` looks like this: ```ts theme={null} [ { type: "operation.started", operationId: "c1", operation: { kind: "tool", name: "get_weather", input: { city: "Beijing" } } }, { type: "operation.finished", operationId: "c1", kind: "tool", output: { temp: 21 }, status: "completed" }, { type: "message", role: "assistant", text: "Beijing is 21°C today." }, ] ``` There are ten object variants in total (`message`, `operation.*`, `input.requested`, and so on; see the [Event stream reference](/docs/reference/events) for the full list). Assertions read exactly this array: `t.calledTool("get_weather")` counts started tool operations, and `t.reply` takes the last assistant `message`. Note that each `send` returns only **this turn's** array. Joining turns into a whole session line is the Runner's job; see the next section. Most of the time you do not write these objects by hand either: an official converter returns a complete `Turn` with `events`, `usage`, and `status` filled in. For choosing a converter, see [Write send](/docs/tutorials/write-send). `data` is not a pocket for "whatever". It has only one rule: **the eval case declares what it wants with a schema on send. `data` exists only when declared, and it has the declared type when you get it.** ```ts theme={null} // Eval side: the output declaration is both a requirement for the application and the type source of turn.data const turn = await t.send("What is the total on this invoice?", { output: z.object({ amount: z.number(), currency: z.string() }), }); turn.data.amount; // Type is number, not unknown; no manual narrowing ``` The declaration is lowered to JSON Schema and reaches the Adapter through `input.outputSchema`. After the Adapter gets the application's response, it puts the structured output in `data` (for example, `JSON.parse(reply.content)`). **The Runner enforces validation**: if `data` does not match the declaration, the turn is immediately `failed` and reports the difference. There is no path where a wrong object slips in and an assertion silently passes. Conversely, a turn that did not declare `output` has no `data`: an application that only returns text never touches this field. Do not copy the raw response body into it to fill space. ### After send returns: where the four Turn fields go Once `send` returns, the Adapter's work is done; everything else is the Runner's job. Each of the four fields has a clear destination: | Turn field | What the Runner does with it | Where the eval author sees it | | - | - | - | | `events` | Appends it to this session line and normalizes it into messages and logical tool occurrences. The last assistant `message` also updates `t.reply` | Scoped assertions such as `turn.calledTool()` and `turn.event(...)`; `t.events` can be inspected directly | | `status` | Records this turn's final state. On `"waiting"`, it collects this turn's `input.requested` into the pending list | `t.succeeded()` or `t.check(turn.status, equals("waiting"))`. After `"waiting"`, continue with `t.respond()` | | `usage` | Adds it up per turn for the session line and the whole run | `maxTokens` / `maxCost` assertions and usage in reports | | `data` | Validates it against the `output` schema declared on send; a mismatch makes the turn `failed` immediately. Saved only if it passes | `turn.data` (strongly typed by the declaration), `t.check(turn.data, equals(...))` or `matches(...)` | The handle you get from `await t.send()` is a view of this one Turn: turn-level assertions see only this turn's `events` and fields, and `t`-level assertions see what has accumulated on the whole session line. So the Adapter does not need to "cooperate" with any assertion. Fill these four fields correctly, and assertions, scoring, and reports all happen downstream automatically. ## Where capabilities come from: proof by construction, not a questionnaire `defineAgent` builds a Direct Agent: the Runner calls a function, SDK, or service endpoint directly, and the target can be local or remote. A Direct Agent's `setup`, `send`, and `teardown` never get a Sandbox. `defineSandboxAgent` builds a Sandbox Agent, and only its callbacks get a `SandboxAgentContext` with a real `sandbox`. | What it unlocks on `t` | What proves it | What you write | | - | - | - | | `t.send`, `t.sendFile`, `t.check`… | Every Agent has them | Nothing | | Multiple `t.send()` calls, `t.reply`, `t.newSession()` | `send` uses the `ctx.session` continuation accessors (typed slot or `id` + `capture()`); without them every turn is a new conversation | Nothing; the accessors are already on `ctx` | | `t.respond()`, status checks, and other HITL | send has returned `"waiting"` + an `input.requested` event | Nothing; doing it means having it | | Positive assertions such as `turn.calledTool()` / `turn.toolOrder()` | events contain paired `operation.started` / `operation.finished` | Nothing; if the events exist, you can assert | | Negative assertions such as `t.notCalledTool()` / `t.usedNoTools()` being **trustworthy** | Events come from an official converter with a completeness proof | Nothing; the proof travels with the converter's return value | | `t.sandbox`, `t.sandbox.fileChanged()`, and similar | Built with `defineSandboxAgent` | Nothing | | Trace channel and the `niceeval view` waterfall | A `tracing` block exists (spans only go into the waterfall, not into assertions) | Nothing; you write the block anyway | The negative-assertion row is the only one with levels. `notCalledTool` asserts that something "did not happen", which requires "all events". That holds only when the source itself has a completeness contract (direct SDK native event streams, AI SDK `result.steps`, Responses `output`). A handwritten mapping has no proof, so a negative assertion warns at runtime that it is not trustworthy instead of silently passing. So there is no gap of "declared but cannot deliver" either: trust follows the source of the events, not the author's diligence. For exact obligations per capability, see the [Capability reference](/docs/reference/capabilities). ## Related reading * [Write send](/docs/tutorials/write-send) — The hands-on tutorial: from sending one message to a full integration in seven steps, each unlocking a set of assertions. * [Connect your Agent](/docs/tutorials/connect-your-agent) — The integration overview: minimal integration, parameter channels, and the incremental map. * [Drive](/docs/explanation/drive) — The eval-side view: how to use `t.send()`, `t.newSession()`, and HITL. * [Assert](/docs/explanation/assert) — The full assertion vocabulary driven by the standard event stream. * [Overall model](/docs/explanation/overview) — How eval cases, Experiments, Adapters, the Runner, and Record connect into one run. # NiceEval Assertions and Matches Source: https://niceeval.com/docs/explanation/assert A Match describes how to compare a value. Calling a check method registers one Assertion immediately, and the methods chained after it only configure that one. NiceEval evaluates by Assertion, and one Assertion is one check. When you call `t.check(value, match)`, `t.judge(value, definition)`, `turn.succeeded()`, or `calledTool(...)`, NiceEval registers one Assertion immediately. There is no step where you first create a check object and then hand it to another API to run. The handle returned by the call only configures the Assertion just registered, for example to add a label, set a gate, or score it. ```ts theme={null} import { includes } from "niceeval/expect"; t.check(turn.message, includes("Completed")).label("Reply says it completed"); turn.succeeded().label("Turn completed"); turn.calledTool("get_weather").label("Looks up weather"); t.judge({ question, answer: turn.message }, answerQuality) .gate(0.8).label("Answer quality"); ``` Each Assertion's `key` and `label` can each be set at most once. Setting either again is an error, even when both writes use the same value. ## Matches only compare Everything `niceeval/expect` exports is a Match, and a Match is one comparison rule. It is reusable and never changes; the same input always gets the same result, and it has no side effects. A Match itself carries no gate, score, or control flow. Those are all configured on the handle. `t.check(value, match)` takes only these two arguments. It reads `value` at the call and registers the Assertion. ```ts theme={null} import { equals, includes, matches, satisfies, toolMatch } from "niceeval/expect"; t.check(turn.message, includes("Order confirmed")); t.check(turn.data, equals({ status: "refund", amount: 42 })); t.check(turn.data, matches(OrderSchema)); t.check(turn.message.length, satisfies("Reply is not empty", (length) => length > 0)); ``` Whether the result is `matched` or `mismatched` only says whether the value satisfies this rule. It does not say whether the rule itself is written correctly. When a task allows several correct results, the rule must cover all of them. `equals(expected)` does a deep equality comparison; for strings, it requires an exact match. If the task accepts several equivalent forms, first parse or normalize the output into the value the task cares about, then compare. Compare strings directly only when the literal form is itself the task requirement. ## Check on a Turn, a Session, or the whole eval Methods such as `succeeded` and `calledTool` register one Boolean Assertion when called. The object you call them on decides which evidence they see: * `turn.*` sees only that one Turn. * `session.*` sees everything in that Session up to the call. * `t.*` sees every Session that has started at the time of the call. ```ts theme={null} const turn = await t.send("Run checks and fix the problem."); turn.succeeded().label("Turn succeeded"); turn.calledTool("shell").label("Uses shell"); turn.calledTool(toolMatch("read_file").exactly(2)).label("Reads twice"); ``` `calledTool` does not take a count argument. Put the count on `toolMatch`, for example `.exactly(2)`. Conditions on `input`, `output`, and `status` also go in `toolMatch`: use `jsonMatch` for JSON structure and `commandMatch` for command arguments. `notCalledTool` accepts the same matcher, but without a count. ## Pass/fail and scored `defineEval` is pass/fail. Boolean checks directly affect the Attempt's verdict: one `mismatched` makes the final verdict `failed`, but later checks still run and register. Continuous-value checks and Judges give measurements between 0 and 1. By default, measurements do not affect the verdict. After you call `.gate(minimum)` on the handle, falling below the minimum gives `failed`. ```ts theme={null} t.judge({ task, answer: turn.message }, actionableQuality) .gate(0.8).label("Actionable steps"); ``` `defineScoreEval` is scored. It has both a verdict and an accumulated `score`. The score is the sum of each item's contribution, with no implied total or percentage. By default, an Assertion only records its result and does not score. Adding `.score(n)` makes it contribute. A satisfied Boolean adds `n` and an unsatisfied one adds 0; a measurement `m` contributes `m * n`. `t.score(n)` adds points directly, and its returned handle can only set `key` and `label`. ```ts theme={null} turn.calledTool("search"); // Saves only the evaluation turn.calledTool("search").score(2); // +2 when matched t.check({ task, answer: turn.message }, completeness).score(5); // +4 when the measurement is 0.8 t.score(5); // Directly +5 ``` In a scored eval, a measurement can score without a gate. When you need a quality gate, call `.gate(minimum)`. It does not affect that item's score, so the same Assertion can have both a gate and a score. A low score alone is not a failure. Only when a gate is set and not met is the verdict `failed`, and the score already earned is kept. A check without `.score()` does not show `+0`. A scored eval with no scoring items normally gets `score: 0`. ## Stop later steps `.orStop()` stops later steps when the check is not satisfied. It returns a Promise and must be awaited: * A Boolean check uses its own condition. * For a measurement with no gate yet, use `.orStop(minimum)` to set the minimum for stopping. * If `.gate(minimum)` is already written, use `.orStop()` with no argument to reuse the same minimum. ```ts theme={null} const turn = await t.send("Search for information and explain the conclusion."); await turn.succeeded().orStop(); const search = await t.check(turn.message, includes("conclusion")).orStop(); ``` Stopping only affects code after the `await` that has not run yet, and that code produces no results. Ordinary JavaScript side effects that already happened are not undone, and concurrent tasks started earlier are not cancelled. The verdict after a stop depends on the reason: * Stopping because a gate was not met gives `failed`. * A measurement that uses only `.orStop(minimum)` without a gate and falls below the minimum only stops later steps. It does not give `failed` for that reason. * Stopping because of `unavailable` or `errored` gives `errored`. After the first stop, this Assertion's configuration can no longer change. Awaiting `.orStop()` with no argument again returns the same Promise. ## Judge `defineJudge` defines a Judge. `t.judge(material, definition)` only accepts a Judge definition and is shorthand for `t.check(material, definition)`; both take the same execution path. Both pass/fail and scored evals can use `.gate(minimum)`, and scored evals can also use `.score(n)`. One Assertion calls the Judge only once. For material, configuration, and failure behavior, see [Judge](/docs/explanation/judge). ## Missing evidence and saved results Missing evidence must not be treated as an ordinary failure to match. When the result is `unavailable` or `errored`, the reason and redacted evidence are kept. Each Assertion's result is also more than "passed" or "did not pass". It saves three kinds of content: * The value being checked: safe structured content, or a stable reference to it. * Which rule was used: the identity and complete configuration of the Match or evaluation function. * The evaluation result, plus the information needed for gates, scoring, and display. With `t.check(a, b)` and `t.judge(material, definition)`, you provide the value being checked and the rule. Methods such as `calledTool` and `succeeded` get the value and build the Match for you, and save the same things. Two examples: * `t.check(await t.sandbox.runCommand(...), commandSucceeded())` saves the command name, arguments, exit status, and duration of that command, plus redacted stdout and stderr (content or references). * `calledTool(...)` saves every tool call it saw: the call identity, redacted input, status, references to output or errors, whether the record is complete, and which events matched. When nothing matched, it still saves the scope it looked at and the candidate calls, not just `false`. So `query`, `view`, and the source view can all build text and interfaces directly from the saved result, without rerunning the Match or calling the Judge again. The `expected` and `received` shown in interfaces are generated at read time from this content; they are not just two stored strings. Assertion only defines what must be saved. Record decides how it is written to disk. When the data needed for a decision cannot be saved safely, the result must be `unavailable`; it cannot keep only `matched` or `mismatched`. ## Related reading * [Match Reference](/docs/reference/expect) — Built-in Matches, composition, and custom Matches. * [Eval API](/docs/reference/define-eval) — The complete field set for `t`, Session, Turn, and handles. * [Pass/fail and scored evals](/docs/tutorials/evaluation-kinds) — The complete form of both kinds of eval. * [Judge](/docs/explanation/judge) — Judge and model configuration. # How to drive an Agent in NiceEval: send, sessions, and HITL Source: https://niceeval.com/docs/explanation/drive t.send() and the Turn it returns, t.sendFile(), multi-turn conversations, t.newSession(), and human-in-the-loop (HITL) handled through respond(). Before writing an assertion or Judge check, make the Agent do something. **Drive** is the part of `test(t)` that sends input and receives results: `t.send()`, `t.sendFile()`, `t.newSession()`, and the HITL methods `t.respond()` / `t.respondAll()`. Drive produces a **Turn**. Every assertion and Judge check in [NiceEval](https://niceeval.com/) reads data from a Turn; see [Assert](/docs/explanation/assert) and [Judge](/docs/explanation/judge). ## `t.send()` and the `Turn` it returns `t.send(input)` is the Adapter's basic execution verb. Under the hood, it calls the Adapter's `send(input, ctx)` and normalizes the result into a `Turn`: ```ts theme={null} const turn = await t.send("What's the weather like in Brooklyn today?"); turn.message; // The last assistant message turn.data; // Structured output: present only if this send declared output; its type follows that declaration (below) turn.status; // "completed" | "failed" | "waiting" turn.events; // This turn's StreamEvent[] turn.usage; // { inputTokens, outputTokens, ... }, only if the Adapter reported it turn.succeeded().label("Turn completed"); // This turn did not fail or remain on an unanswered HITL request ``` `t.reply` is shorthand for the main session's last assistant message. It is equivalent to the `turn.message` from the latest `t.send()`. `t.events` is the full accumulated event stream for the main session. When you need structured output, declare `output` on `send` using a Standard Schema such as zod. The declaration both asks the application for that output and supplies the type of `turn.data`: ```ts theme={null} const turn = await t.send("What is the total amount on this invoice?", { output: z.object({ amount: z.number(), currency: z.string() }), }); t.check(turn.data.amount, equals(3200)); // turn.data is { amount: number; currency: string } ``` The Runner validates the value against the declaration immediately after the Adapter returns. If `data` does not match, the turn is immediately `failed` and reports the difference. A turn that did not declare `output` has no `turn.data`. It is not a pocket for “whatever the Agent happened to return”; the field exists only when you declare it and is strongly typed when it does. `turn.succeeded()` looks only at this turn: its `status` and whether it is waiting for an unanswered HITL request. If later turns depend on an earlier one succeeding, use `await turn.succeeded().orStop()`. ## One turn with a file: `t.sendFile()` `t.sendFile(path, text?)` reads a local file relative to the eval case's directory, infers a MIME type from the extension, and attaches it to this turn's input as a data URL: ```ts theme={null} const turn = await t.sendFile("fixtures/invoice.png", "What is the total amount on this invoice?"); t.check(turn.message, includes("$")); ``` ## Multi-turn conversations Each `await t.send(...)` is a new turn in the same conversation. Assign each response to a local variable so you can assert that individual turn as well as use run-level `t` assertions: ```ts theme={null} const draft = await t.send("Help me draft a follow-up email."); draft.succeeded().label("Draft completed"); t.check(draft.message, includes("Best regards")); t.judge({ request: draft.input, draft: draft.message }, toneQuality) .gate(0.6).label("Professional tone"); await t.send("Okay, send it."); t.calledTool("send_email").label("Sent"); ``` Whether multi-turn `t.send()` calls truly continue their context depends on whether the Adapter's `send` uses `ctx.session` session-continuation storage, either a typed slot or `id` + `capture()`. Without it, every turn is a new conversation. See [Adapter](/docs/explanation/adapter) and [Write Send](/docs/tutorials/write-send). ## Independent sessions: `t.newSession()` `t.newSession()` opens a second conversation line parallel to the main session, without interfering with it. Its returned session handle has the same drive API (`send` / `sendFile` / `respond` / `respondAll`) and scoped-assertion vocabulary, but sees only that session's events: ```ts theme={null} await t.send("My name is Xiaoming."); await t.send("What is my name?"); t.check(t.reply, includes("Xiaoming")); // The main session remembers const fresh = t.newSession(); await fresh.send("What is my name?"); t.check(fresh.reply, satisfies("No memory leak", (reply) => !reply.includes("Xiaoming"))); ``` Do not assume that `t.newSession()` inherently guarantees isolation. The Runner only guarantees that the new session line starts with empty state. Opening a fresh conversation with the application is the Adapter's job. An Adapter that ignores its session state and always resumes the same underlying context makes `t.newSession()` silently share state without an error. When you write an Adapter, verify isolation with an eval case like the one above before you trust it. ## Human-in-the-loop (HITL) Some Agents stop during a turn to wait for approval or missing information instead of finishing immediately. That turn ends with `status: "waiting"` and has one or more `input.requested` events that say what it needs. For the full mental model—handshake sequence, Adapter obligations, and `rejected` semantics—see [HITL](/docs/explanation/hitl). This section explains how to use it on the eval side. ```ts theme={null} import { equals } from "niceeval/expect"; const draft = await t.send("Draft a follow-up email, but do not send it until I confirm."); t.check(draft.status, equals("waiting")).label("Paused for approval"); const request = t.requireInputRequest({ prompt: /send/, optionIds: ["approve", "reject"], }); await t.respond({ request, optionId: "approve" }); t.calledTool("send_email").label("Sent"); ``` `t.requireInputRequest(filter)` turns a pending HITL request into a concrete value you can inspect and answer. It throws if zero or more than one pending request matches, so use as many filter fields as you can—`id`, `prompt`, `display`, `action`, `optionIds`, or `input`—to disambiguate. `t.respond(...)` answers the request and emits the next turn. Each argument is one answer. The string form is only for when exactly one request is pending. When several requests pause together, name each one explicitly with `{ request, optionId }` or `{ request, text }`; argument order is never used to guess. Under the hood it is another ordinary `send`: answer text goes to `input.text`, while structured answers go to `input.responses` one per request. A response that selects a request option carries `{ requestId, optionId }`; a free-text response carries `{ requestId, text }`. The Adapter does not need to parse the text: `requestId` identifies which answer belongs to which request. See [Inputs for the different answers](/docs/explanation/adapter#inputs-for-the-different-answers) for how each response looks to the Adapter. If the current turn has multiple pending requests of the same kind that should all receive the same answer, such as approving a batch of file changes one by one, use `t.respondAll(optionId)` to answer them at once. It validates `optionId` against every pending request first. If the option is absent from a request's `options`, it throws instead of silently sending the wrong answer: ```ts theme={null} await t.send("Submit this batch of changes for approval one by one."); t.requireInputRequest({ display: /approval/ }); await t.respondAll("approve"); t.succeeded().label("All completed"); ``` ## Related reading * [HITL](/docs/explanation/hitl) — The complete concept of paused turns waiting for people: handshake sequence and obligations on both sides. * [Assert](/docs/explanation/assert) — The assertion vocabulary that reads `Turn.events` and `Turn.data`. * [Judge](/docs/explanation/judge) — Assess open-ended output with a rubric and material that has meaningful fields. * [Adapter](/docs/explanation/adapter) — Where capabilities come from: what unlocks `t.newSession()`, HITL, and tool-related assertions. # Eval cases in NiceEval: lifecycle, verdicts, and files Source: https://niceeval.com/docs/explanation/evals An eval case says what to send and what counts as correct, without binding to a system under test. Learn how eval cases are discovered, scheduled, scored, and reported. An eval case says what to send and what counts as correct. It is usually exported from a `*.eval.ts` file and declared with `defineEval`. It reads like a unit test, but the output of the system under test is nondeterministic. The same eval case runs several times according to the Experiment's `attempts`. Each run is one Attempt, and together they give a pass rate or a score. ## What an eval case contains ```ts theme={null} import { defineEval } from "niceeval"; import { includes, jsonMatch, toolMatch } from "niceeval/expect"; export default defineEval({ description: "Brooklyn weather query", async test(t) { await t.send("What's the weather like in Brooklyn today?"); t.succeeded(); t.calledTool( toolMatch("get_weather", { input: jsonMatch({ city: "Brooklyn" }) }).exactly(1), ); t.check(t.reply, includes("sunny")); }, }); ``` Core fields: | Field | Description | | - | - | | `description` | A human-readable description shown in reports | | `sandbox` | Optional. This eval case's own Sandbox configuration; omit it when the Experiment already names the prebuilt environment | | `test(t)` | The interaction and assertion logic | An eval case does not declare which Agent to use, so the same eval case can run against different Agents under different Experiments. The Agent is an Experiment field, and the CLI does not override it ad hoc. Do not write `id` or `name` by hand. [NiceEval](https://niceeval.com/) derives the ID from the file path. ## Path is identity `evals/weather/brooklyn.eval.ts` has the ID `weather/brooklyn`. Positional arguments after the Experiment name filter by ID prefix: ```bash theme={null} npx niceeval exp local weather npx niceeval exp local weather/brooklyn ``` IDs follow the directory structure, so they are stable and readable. Filtering matches by string prefix: `terminal-swe-bench` also matches `terminal-swe-bench-astropy-1`, and the next character does not have to be `/`. ## Lifecycle The Runner loads `*.eval.ts` files and Fixture directories under `evals/`. It combines concurrency, result reuse, `attempts`, and earlyExit to produce the Attempts for this run. `t.send()` calls the Adapter selected by the Experiment and receives a standard `Turn`. NiceEval records every check: value assertions, assertions on the Turn and Session, Judge scores, and each item's score contribution. A pass/fail eval combines the check results into a final verdict; a scored eval accumulates a score. The terminal and Reporters output results; the Run and published Attempts are written to `.niceeval/record.sqlite`. ## Four verdicts Each Attempt of a pass/fail eval gets exactly one of four verdicts: Every required Boolean check is satisfied, every measurement with `.gate(minimum)` reaches its minimum, and no execution error occurred. At least one required Boolean check is not satisfied, or a measurement with `.gate(minimum)` falls below its minimum. An execution exception, a timeout, or a mistake in the eval code means this run cannot reach a trustworthy conclusion. The eval case skipped itself, usually with `t.skip(reason)`. A scored eval is read mainly by its accumulated score, and higher is better. With no scoring items, the score is `0`. Reports show the score by default and do not count it toward the pass rate, but the verdict is still kept. A result with a `failed` verdict does not enter the success ranking even if its score was fully computed. The `points` in `.score(points)` is the weight of that item, not the total score. Reports do not convert scores to percentages automatically. When one Experiment contains both kinds of evals, the report shows the pass rate and the score separately. ## Verdicts and measurements In pass/fail evals, Boolean checks affect the verdict by default. In scored evals, Boolean checks are only recorded by default. Continuous measurements and Judge scores are numbers between 0 and 1. Both kinds of evals use `.gate(minimum)` to set a minimum, and falling below it gives `failed`. In a scored eval, the score already earned is kept when a gate is not met. See [Assertion and Match](/docs/explanation/assert) for the complete rules. ## The `*.eval.ts` convention Only files ending in `.eval.ts` are discovered. Directories only form ID prefixes: ```text theme={null} evals/ └─ billing/ └─ refund.eval.ts # id: billing/refund ``` ## Array exports and data-driven tests (dataset fan-out) A file can also default-export an array of `defineEval(...)` calls, generating multiple eval cases from the same logic: ```ts theme={null} export default rows.map((row) => defineEval({ description: row.task, async test(t) { await t.send(row.prompt); t.check(t.reply, equals(row.expected)); }, }), ); ``` This generates IDs such as `sql/0000` and `sql/0001`. See [Data-driven tests](/docs/tutorials/dataset-fanout). ## Related reading * [Experiment](/docs/explanation/experiment) — The other half: what to evaluate and how to run it, and why it is separate from eval cases. * [Assert](/docs/explanation/assert) — Assertion and verdict rules. # Experiment: what to evaluate and how to run it Source: https://niceeval.com/docs/explanation/experiment An Experiment is a check-in-able run configuration: which Agent and model the same set of eval cases runs against, which flags are on, and how many times to run. Eval cases and Experiments are kept separate so the same eval cases can run against different systems. An Experiment says which Agent to run against, which model to use, which flags to turn on, and how many times to run. Use it to compare models, prompts, and feature toggles. `flags` are the feature flags of an A/B test, and one Experiment is one set of flag values. ## What an Experiment contains An Experiment is a check-in-able TypeScript file under `experiments/`. It has only a few core fields: ```ts theme={null} // experiments/concise.ts export default defineExperiment({ description: "Concise prompt variant", agent: webAgent({ baseUrl: "https://staging.example.com" }), // A configured agent instance model: "gpt-5.4", // Passed through to the Adapter as ctx.model flags: { promptVariant: "concise" }, // Passed through as ctx.flags; t.flags in the eval attempts: 3, // How many times each eval runs (to counter randomness) }); ``` * `agent`: what to evaluate. Put an already configured Adapter instance here. Pass the system's address and authentication to the Adapter when you create the instance, not in other Experiment fields. * `model` / `flags`: experiment conditions handed to the Adapter. `flags` only accepts flat strings, finite numbers, or booleans, and NiceEval does not interpret their meaning. When the Adapter declares `parseFlags`, the values are validated and normalized first, then handed to the Adapter through `ctx`. The Adapter forwards them with the request, and the application switches behavior based on the values. Model comparison (Tier 1) and feature A/B tests (Tier 3) both use this path. See [Integration tiers](/docs/explanation/tier). * Run parameters such as `attempts`, `budget`, concurrency, and `sandbox`: how to run and how much. For the full field set, see [Write Experiments](/docs/tutorials/write-experiment#common-fields). An Experiment also has a pair of Experiment-level Hooks, `setup` / `teardown`. They run once for the whole Experiment on your own machine, and start and stop services shared by all Attempts, such as a tunnel to an internal service or a mock server just for this Experiment. `setup` runs before the first Attempt of this Experiment starts. `teardown` runs after all Attempts finish, and also runs when the run is interrupted. But `teardown` runs only if `setup` has already run. To prepare the environment inside a Sandbox for the whole Experiment, attach the preparation steps to `.before()` on the `sandbox` field. Steps whose content does not change can be reused next time. Dynamic callbacks can register cleanup with `context.onCleanup()`. See [Start shared services for an Experiment](/docs/tutorials/experiment-services) and [Sandbox · Prepare the Sandbox in a deterministic order](/docs/tutorials/sandbox-providers#prepare-the-sandbox-in-order). When different eval cases in the same Experiment need different prebuilt environments, write the `sandbox` configuration that names the prebuilt environment on each eval case, and leave it out of the Experiment. Reuse repeated configuration with ordinary TypeScript functions; no extra registration is needed. The same Experiment can still cover all eval cases, and NiceEval merges each eval case's configuration with the Experiment's configuration one by one. See [Write Experiments · Different eval cases need different environments](/docs/tutorials/write-experiment#different-evals-need-different-environments). ## Matrix comparisons Write one Experiment file for each variant you want to compare. Two models means two files that differ only in the `model` line. A prompt A/B test means two files that differ in one parameter. Run the same set of eval cases under each Experiment, then compare the results published at the same moment. Each Experiment computes its denominator from the eval cases it ran, and NiceEval does not force a common intersection. For what is worth comparing and how to read the results, see [Write Experiments](/docs/tutorials/write-experiment#compare-two-models). Put values used only to group reports, which do not affect the run, in `labels`. They are recorded on the Run and are not passed to the Adapter or eval cases. Running again after changing `labels` creates a Run with the new labels, and unaffected earlier results are reused as usual. Historical Runs keep the `labels` they had at the time. ## Related reading * [Write Experiments](/docs/tutorials/write-experiment) — Compare models and prompts, read comparison results, and common fields. * [Start shared services for an Experiment](/docs/tutorials/experiment-services) — How to write `setup` / `teardown` and when they run. * [Eval cases](/docs/explanation/evals) — The other half: what an eval case is, its lifecycle, and its verdicts. * [Tier](/docs/explanation/tier) — The tier in which `model` and `flags` each take effect. # Human-in-the-loop (HITL): pause a turn, answer, and continue Source: https://niceeval.com/docs/explanation/hitl What human-in-the-loop is, how it appears in Claude Code, Codex, and AI SDK applications, and how NiceEval turns it into an eval path: a waiting turn pauses, input.requested says what it needs, and t.respond answers for the human so the same turn can continue. HITL (human-in-the-loop) is when an Agent pauses during execution and hands a decision back to a person. It does not continue until it receives an answer. This is not an error; it is an intentional safety or information gate in the Agent product. A person approves a sensitive operation, supplies missing information, or chooses between several options. You probably use it every day: * **Claude Code** pauses before it runs a risky command or changes a file outside its permissions, then asks “Allow this?” It executes only after approval, and takes a different path after rejection. * **Codex** uses approval modes such as suggest and auto-edit to decide which actions need confirmation first. This is the same kind of gate. * **AI SDK applications** often surface a tool call to the frontend when a tool has no `execute`. A person confirms it in the UI, the result returns through `addToolResult`, and the model continues. This is the most common approval-gate pattern in custom applications. For a subject under test, this “pause and wait for a person” branch needs regression coverage like any other behavior. Does the expected action happen after approval? Does it truly not happen after rejection? What if it asks the wrong person or pauses at the wrong point? Eval cases run automatically, so they have no person in the loop. [NiceEval](https://niceeval.com/) reduces the “person” to three programmable actions: use `t.check(turn.status, equals("waiting"))` to confirm the Agent paused, use `t.requireInputRequest()` to inspect what it is waiting for, then use `t.respond()` to answer in place of the person. Approval, rejection, and extra information become paths you can write into an eval case. ## How a paused turn is represented: `waiting` + `input.requested` The `Turn.status` returned from `send` has three values: `"completed"` when it has finished, `"failed"` when it has errored, and `"waiting"` when **the turn has not finished and the Agent is waiting for human input**. `waiting` is not a failure; it is a suspension. Once an answer arrives, the same turn continues without resending the request or opening a new conversation. A paused turn must also say what it is waiting for. Every question that needs an answer emits an `input.requested` event with a stable request `id`, the action where it paused (`action`), and optional decisions in `options`, such as approve / deny. Both status and event are necessary: `waiting` tells the eval case that it should answer, while `input.requested` tells it what to answer. ## One complete handshake `t.respond` has no special channel. It is simply **another ordinary `send`**, with the human decision delivered in structured input: Two easy mistakes to avoid: * **A response turn is not a new conversation.** It happens on the same session line, so `ctx.session` remains the same. The Adapter uses it to recover the paused state from the previous turn. * **Do not infer an answer from text.** `input.responses` carries `{ requestId, optionId }` for each request, or `{ requestId, text }` for a free-text answer. The eval side has already verified that an `optionId` exists in the request's options. A typo throws instead of silently reaching the application. See [Inputs for the different answers](/docs/explanation/adapter#inputs-for-the-different-answers). ## On the eval side: three actions ```ts theme={null} import { equals, toolMatch } from "niceeval/expect"; const draft = await t.send("Send a weekly status email to my manager."); t.check(draft.status, equals("waiting")); // ① Assert that this turn paused const req = t.requireInputRequest({ action: "send_email" }); // ② Get the pending request await t.respond({ request: req, optionId: "approve" }); // ③ Answer for the person and emit the next turn t.calledTool(toolMatch("send_email", { status: "completed" })); ``` * `t.check(draft.status, equals("waiting"))` asserts that the turn really stopped in `waiting`. Conversely, `draft.succeeded()` fails on a waiting turn. A paused turn is an explicit state; it is never treated as “finished.” * `t.requireInputRequest(filter)` picks exactly one pending request by matching fields such as `id`, `prompt`, `action`, or `optionIds`. It throws when zero or more than one request matches, which disambiguates several simultaneously paused requests. * `t.respond(...)` answers the request and emits the next turn. When several requests of the same kind need the same answer, such as approving a batch of changes one by one, `t.respondAll(optionId)` handles them at once. Approval and rejection are two branches of one gate. Covering it means writing an eval case for both: the expected action happens after approval, and it does not happen after rejection. For the complete API-by-API use, see [Drive](/docs/explanation/drive#human-in-the-loop-hitl). ## On the Adapter side: two obligations and one continuation HITL asks an Adapter to do exactly three things. The first two happen in the paused turn; the third happens in the next turn when the answer arrives: 1. **Return `status: "waiting"` honestly when a turn pauses.** Do not report a suspension as `"completed"`; otherwise `t.check(turn.status, equals("waiting"))` fails and `succeeded()` can pass incorrectly. 2. **Emit one `input.requested` event for every question waiting for an answer.** Keep `id` stable and populate as many fields as possible. eval-side checks and alignment depend on them. 3. **On the next `send`, return the decision first, then continue.** Take each decision from `input.responses` by `requestId` rather than guessing by order, then continue from where the previous turn paused instead of resending the request. The paused state, such as a partially read SSE stream, lives in `ctx.session` on this session line. The Adapter declares a private typed slot with `createSessionSlot()`, saves it on pause with `ctx.session.set(slot, pending)`, and recovers it on the response turn with `ctx.session.take(slot)`. `take` clears the value as it retrieves it. See step 5 of [Write Send](/docs/tutorials/write-send#step-5-hitl) for the complete streaming + HITL skeleton. Like other [NiceEval](https://niceeval.com/) capabilities, HITL does not have a Boolean declaration: **if you do it, you have it**. Once `send` has returned `"waiting"` and emitted `input.requested`, `t.respond` works. If it has not, the eval case fails explicitly at the status check or `requireInputRequest()` rather than silently passing. See [Adapter](/docs/explanation/adapter#where-capabilities-come-from-proof-by-construction-not-a-questionnaire) for how capability follows from construction. ## Rejection is not a failure A human veto and a tool error are different things. The event stream distinguishes them with different statuses: an operation that a person rejects has `"rejected"` as the tool `operation.finished` `status`, not `"failed"`. That way `t.noFailedActions()` is not tripped by a legitimate human veto, while `t.calledTool(toolMatch("deploy", { status: "rejected" }))` precisely asserts “the call started and a person stopped it.” This is exactly what an eval case for the rejection branch needs to assert. ## Which Agents do not need HITL Not every Agent has this branch. If it does not exist, skip it entirely: the Adapter does not need to handle `waiting`, and the eval case does not use `t.respond`. * **Agents that finish every turn in one pass:** question-answering, retrieval, translation, and other send-and-reply services have no point where they wait for a person. * **Runtime modes with the approval gate disabled:** Claude Code and Codex are HITL products, but when they are subjects under test in a Sandbox, they usually run fully automatically and skip permission confirmation. This is how the built-in `claude-code`, `codex`, and `bub` Sandbox Agents run, so none uses HITL. * **Approval that happens outside the execution loop:** for example, a ticket system may have a person review an Agent's output. That is another system's flow. It does not go through `send`, so an eval case cannot and should not evaluate it. There is one test: use HITL only if your Agent's execution loop has a branch that pauses for a person, then continues after an answer, and you want to write approval and rejection into an eval case. ## Related reading * [Drive](/docs/explanation/drive#human-in-the-loop-hitl) — The complete eval-side usage: status checks, `t.requireInputRequest()`, and `t.respond()` / `t.respondAll()`. * [Adapter](/docs/explanation/adapter#inputs-for-the-different-answers) — The structured input that reaches an Adapter, in four typical forms. * [Write Send](/docs/tutorials/write-send) — Adapter-side practice: typed session slots and the complete streaming + HITL skeleton. * [Connect Your Agent](/docs/tutorials/connect-your-agent) — The integration overview: minimal integration, parameter channels, and the incremental map. # Judge in NiceEval: assess open-ended output Source: https://niceeval.com/docs/explanation/judge Use a rubric, anchors, and explicit material to assess open-ended output, adding images when needed. Judge uses a separate grading model to assess factual consistency, summary faithfulness, and explanation quality. It is a managed evaluator. Ordinary custom `ScoreMatch` values stay pure functions; `defineJudge`, the built-in factories, and the advanced managed `defineScoreMatch` all take the same Assertion path. ## Declare a scoring definition Write the criterion at module scope. `rubric` describes the dimensions to evaluate, and `anchors` give calibration points on a continuous quality scale: ```ts theme={null} import { defineJudge } from "niceeval"; const explainsRisk = defineJudge({ name: "explains-risk", rubric: "Based on change, evaluate whether explanation covers compatibility, rollback, and data risks.", anchors: [ { measurement: 0, description: "Does not explain a concrete risk or rollback plan" }, { measurement: 0.5, description: "Explains some risks but omits one key point" }, { measurement: 1, description: "Fully explains compatibility, rollback, and data risks" }, ], }); ``` When `anchors` is omitted, NiceEval uses 0 and 1 as the two default endpoints. A measurement can be any finite value in `[0,1]`; the endpoints do not limit the output to two classes, and they are not model confidence. ## Select evaluation material Pass the values needed for the decision directly to `t.judge`. The eval's `judge` only selects the model configuration for this eval: ```ts theme={null} export default defineEval({ judge: "judge-model", async test(t) { const change = "Modify a persistent field and provide a rollback plan"; const turn = await t.send(`Explain this change: ${change}`); t.judge({ change, explanation: turn.message }, explainsRisk) .gate(0.8) .label("Risk explanation quality"); }, }); ``` Material uses strings, finite numbers, booleans, null, arrays, and plain objects. Field names should express application meaning, such as `change` and `explanation`; you do not need to join the object into text first. NiceEval creates a bounded JSON snapshot when the Assertion is registered, so later changes to the original object do not change the request. When you need to inspect image content, put `judgeImage({ body, mediaType })` in the material. It freezes PNG or JPEG bytes without creating another Judge or sending an extra request. The image and text are sent in the same Judge call and saved in the same Assertion's evidence. For the setup steps, see [Add image material](/docs/tutorials/verify-judge#attach-image-material). `t.judge(material, explainsRisk)` and `t.check(material, explainsRisk)` each register one measurement Assertion. They share the material snapshot, budget, evaluation, audit, result saving, and handle; there is no Match allowlist. ## Use built-in Judges directly Root `t`, Session, and Turn all provide built-in methods that return an Assertion's `MeasurementAssertionHandle`. For example: ```ts theme={null} import { eventMatch } from "niceeval/expect"; const answer = await t.send("List the risks and rollback steps."); answer.closeQA("Does this turn explain the risks and actionable rollback steps?") .gate(0.8).label("Rollback explanation"); // For a special check, select all assistant messages explicitly. answer.closeQA( eventMatch("message", { role: "assistant" }), "Do all messages clearly explain the risks?", ).gate(0.8); ``` `closeQA(selector, question, options?)` sends all matched material to one Judge call in its original order. The question is an acceptance question answered only from the material, not a preset answer. An Agent's `closeQA(question, options?)` uses the complete recorded history of that Turn, Session, or Attempt by default, including messages and tool facts. It does not pick only the last reply or successful operations. An explicit EventMatch selects from the three event views. For questions about tool inputs and receipts, use a ToolMatch or a custom MaterialMatch. All these forms compose a scoring Match and pass it to the same `check`. Application assertions such as `usedNoTools()` wrap a fixed criterion and do not accept another Match. The result is 1 when the condition holds, 0 when it does not, and `unavailable` when evidence is insufficient. A complete empty set gives 0 without calling the model; missing or oversized material is not filled with zero or truncated before scoring. An Adapter provides the application ctx through `create`, and the synchronous reader in `defineMaterialMatch` reads the current read-only ctx when the Assertion is registered. A selector's predicate judges one item only; `and` constrains the same item. Use `t.check(selector).gate()` for ordinary existence checks and `t.closeQA(selector, question).gate(0.8)` for a question about the whole set. `factuality`, `faithfulness`, `instructionFollowing`, and `pairwisePreference` take explicit material, build a Match, and pass it to the same `check`. ## Configure the Judge Provider Project configuration selects the service explicitly with a named Provider factory. This example connects to OpenAI or an explicitly compatible gateway: ```ts theme={null} import { defineConfig } from "niceeval"; import { OpenAIProvider } from "niceeval/judge"; export default defineConfig({ judgeRuntime: OpenAIProvider({ model: "judge-model", baseUrl: "https://gateway.example.com/v1", apiKeyEnv: "JUDGE_GATEWAY_KEY", timeoutMs: 180_000, maxOutputTokens: 512, }), }); ``` | Factory | Service | Default key variable | | - | - | - | | `OpenAIProvider` | OpenAI or an explicitly compatible gateway | `OPENAI_API_KEY` | | `VercelProvider` | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | | `OpenRouterProvider` | OpenRouter | `OPENROUTER_API_KEY` | | `TypesafeProvider` | TypeSafe System One | `TYPESAFE_API_KEY` | All four factories are exported from `niceeval/judge`. The Vercel factory means AI Gateway, not Vercel Sandbox or a general AI SDK model object. When one eval only needs a different model, write just this field: ```ts theme={null} export default defineEval({ judge: "another-model", async test(t) { const answer = await t.send("What is the capital of France?"); t.factuality({ input: "What is the capital of France?", output: answer.message, expected: "Paris" }).gate(1); }, }); ``` This keeps the global Provider's endpoint, credential source, and execution limits, and replaces only `model`. Evals and Experiments can also provide a complete Provider, which replaces the service, default model, and limits as a whole. There is no rule that guesses the service from the hostname, model prefix, or an existing key. NiceEval uses the selected Provider's managed protocol request to get a finite `[0,1]` measurement and a public rationale. Chat services use forced-function requests, and TypeSafe uses `/systemone`. With no model or key configured, no network request is sent and the Assertion is `unavailable`. Transport failures or timeouts are `unavailable`; HTTP 400, protocol incompatibility, or invalid responses are `errored`. Static configuration validation does not probe the network. TypeSafe uses the `/systemone` protocol. It supports score, classify, and batch classify, but not extract. So `faithfulness()` on `TypesafeProvider` gives `unavailable`; it is not converted to an overall score or a different denominator. ## Thresholds and scores Both pass/fail and scored evals call `.gate(minimum)` on the registered handle to form a quality gate. Scored evals can also call `.score(points)`, and the contribution is the measurement times points. Gate and score can be combined in either order, and both run the Judge only once. Successful requests save the fixed material actually sent. A path, URL, alt text, or ordinary base64 string is not visual input; only explicit image material becomes the image part of the model API request. # NiceEval's overall model: the parts of an evaluation Source: https://niceeval.com/docs/explanation/overview What eval cases, Experiments, Adapters, Sandboxes, the Runner, and Record each handle, how they connect into one run, and why the same eval runs more than once. An evaluation answers one question: can the system under test do this task correctly with this configuration? NiceEval splits that question into several parts, and each part handles one thing. ```text theme={null} Eval case (what counts as correct) Experiment (against what, and how to run) \ / \ / Runner (schedules Attempts, scores) │ Adapter (connects to the system under test) │ System under test (direct, or running in a Sandbox) │ Record (stores each Attempt's result) │ show (terminal) · view (browser) · query (JSON) ``` ## Eval cases: what counts as correct Eval cases live in `evals/*.eval.ts`. They say what to send and what to check. They do not say which Agent or model to run against, so the same eval case can run against different systems under test. Checks come in two kinds. Deterministic facts get deterministic rules, such as which tool was called or whether the tests passed. Open-ended quality goes to a Judge, a grading model that scores against the criteria you write. See [Eval cases](/docs/explanation/evals) and [Judge](/docs/explanation/judge). ## Experiment: against what, and how to run Experiments live in `experiments/`. They say which Adapter, which model, which flags, and how many times each eval runs. To compare two models, write two Experiments that differ only in `model` and run each against the same set of eval cases. See [Experiment](/docs/explanation/experiment). ## Adapter and Sandbox: how to reach the system under test An Adapter is connection code you write. It sends the actions in an eval case to the system under test, then translates the response into events NiceEval can read, such as replies, tool calls, and usage. You can start by implementing only `send` and add OTel or flags when you need them. See [Adapter](/docs/explanation/adapter) and [Integration tiers](/docs/explanation/tier). A Coding Agent needs to change code and run commands on a real file system, so NiceEval first creates an isolated Sandbox and starts the Agent inside it. Directly connected Web Agents and business applications do not need a Sandbox. ## Runner and Attempt: why the same eval runs more than once LLM output is nondeterministic. A question answered correctly this time may be answered wrong next time. One run cannot tell you whether a change really helped. So an Experiment uses `attempts` to set how many times each eval case runs, and each run is called an Attempt. The Runner selects eval cases from the Experiment, expands them into a batch of Attempts, and schedules them within the concurrency limits. Each Attempt gets one verdict: `passed`, `failed`, `errored`, or `skipped`. Together, a batch of Attempts gives the pass rate of that eval under that configuration. Scored evals accumulate a score instead of reading a pass rate. See [Pass/fail and scored evals](/docs/tutorials/evaluation-kinds). Read `failed` and `errored` separately. `failed` means the system under test replied normally but got it wrong. `errored` means no trustworthy result came back, for example because the service did not start or a credential expired. It points to an environment problem, not to the capability of the system under test. ## Record: results stay for comparison and investigation When an Attempt finishes, its result is saved together with the conversation, tool calls, file changes, duration, and cost in the project's `.niceeval/record.sqlite`. This is Record. Keeping results serves two purposes. One is investigation: go back to a failure and see what the system under test actually did. The other is reuse: on the next run, results unaffected by your change are reused and only the affected part runs again. See [Rerun and reuse](/docs/tutorials/rerun-and-cache). There are three ways to read results. `show` drills down in the terminal, and both you and Coding Agents use it to investigate. `view` lets you browse conversations and timing in a browser. `query` outputs JSON for scripts and CI. All three reach the same conclusions. See [How run results flow](/docs/explanation/results-flow). ## Next steps * First time using NiceEval: read the [Quickstart](/docs/tutorials/quickstart). * To learn about one part: follow the link at the end of each section above. * To look up fields and commands: go to the [Reference](/docs/reference/define-eval). # Record Source: https://niceeval.com/docs/explanation/record Learn what the project results file .niceeval/record.sqlite stores, how to copy it for someone else, and what is validated when it is read. You want to hand a run's results to a colleague for review, or archive a baseline to compare against later. All of NiceEval's run results live in one file: `.niceeval/record.sqlite` in your project. This is Record. ## What Record contains Runs, in-progress summaries, published Attempts, and their attachments and content are all stored in this file. Cache, credentials, and user-level service state are not. A Run can be found as soon as it is created. An Attempt can be read once it finishes and publishes, without waiting for its Run to end. Published Attempts are never modified again. `query`, `show`, and `view` only read published results. ## Hand results to someone else After a run has fully ended, copy the file directly: ```sh theme={null} cp .niceeval/record.sqlite ./baseline.record.sqlite ``` Copy only `record.sqlite` itself. Do not copy the WAL, checkpoint, or staging files next to it, and do not modify the SQLite content after copying. When you receive someone else's file, point `--record` at it: ```sh theme={null} pnpm exec niceeval show --record ./baseline.record.sqlite pnpm --silent exec niceeval query explain --record ./baseline.record.sqlite --request request.json ``` `view` only reads the current project's `.niceeval/record.sqlite` and does not accept `--record`. ## External files are validated before reading `query` and `show` treat the file named by `--record` as untrusted input. The command reads it only when the format version matches exactly, the SQLite integrity check passes, and the internal data is consistent. When validation fails, the command reports an error. It does not migrate, patch, or read part of the file. When a file is rejected, first confirm it is a complete single file copied after the run ended, and that no other process was writing during the copy. For specific errors and next steps, see [Debug failed eval results](/docs/troubleshooting/debugging#cannot-open-results). Record can contain sensitive business content, such as raw conversations and file changes. Before sharing, you are responsible for permissions, redaction, and retention. ## What Record does not do Record only stores results. It does not compute statistics, comparisons, or pages. NiceEval's fixed built-in queries read the published results, then hand the complete query result to `query`, `show`, or `view`. External code never gets a database connection, a reader object, or SQL access. To choose a way to read results, see [CLI](/docs/reference/cli). # NiceEval's post-run data flow Source: https://niceeval.com/docs/explanation/results-flow Understand where run results are stored, and what each of the three read entry points, query, show, and view, is responsible for. After a run ends, you might read results in the terminal, browse conversations in a browser, or have a CI script read JSON. All three see the same results with different layouts. This page explains where results are stored, how they are read out, and what each of the three read entry points is responsible for. ```text theme={null} .niceeval/record.sqlite in the project (published Runs and Attempts) ↓ NiceEval's fixed built-in queries ↓ query (JSON) | show (terminal) | view (browser) ``` ## Results are stored in one file Each project's run results are stored in `.niceeval/record.sqlite`. This is Record. When `niceeval exp` starts, it creates a Run and records every Attempt planned for this run. Each Attempt publishes on its own as soon as it finishes and can be read right away, without waiting for the whole Run to end. Published Attempts and ended Runs are never modified again. After a run has fully ended, the file itself can be copied or archived. To read a copy someone gave you, add `--record ` to `query` or `show`. NiceEval fully validates the file first, then opens it read-only. ## NiceEval fixes the read rules When results are read, NiceEval's fixed built-in queries decide which Runs to select, up to which point to read, which results are missing, what issues exist, which evidence to use, and how to compare. A query returns plain data. It never hands the caller a database connection, a file path, or a reader object. The built-in queries cover Runs, Attempts, traces, diffs, sources, artifacts, and comparisons between Runs. You cannot customize reads with ad hoc SQL, JSON paths, or formulas. ## Three read entry points * `niceeval show` starts with an overview in the terminal, then drills into one Attempt's conversation, timing, and changes. Both you and Coding Agents use it to investigate problems. * `niceeval query` outputs structured JSON for scripts and CI. Agents also use it when they need exact fields. * `niceeval view` is for people in a browser. It opens an access-protected Insight interface that listens only on a local address. All three entry points read the same query result. Each one only handles its own layout: JSON, terminal text, or browser page. None of them selects results on its own or recomputes pass rates or scores, so the same Run reaches the same conclusions in all three places. For the full commands, see [CLI](/docs/reference/cli). For how the results file is copied and validated, see [Record](/docs/explanation/record). # How a run produces readable results Source: https://niceeval.com/docs/explanation/runner Learn how the Runner creates a Run, publishes Attempts one by one, and why read commands only see published results. You start `niceeval exp` and want to look at the results that are already in before it finishes. Or you want to know which results still count when a run is interrupted halfway. This page explains how the Runner turns a run into results you can read. ## A Run fixes what to run when it starts The Runner creates one Run for each selected Experiment. At creation it records the `runId`, this run's `invocationId`, and every Attempt planned for this run. The plan does not change afterward, so the denominator of the pass rate is fixed at the start. ## Each Attempt publishes on its own when it finishes When an Attempt finishes and its results are fully written, it publishes on its own. Once published, it can be read, compared, reused in the next run, or explicitly adopted with `niceeval accept`, even while its Run is still `active`. Unpublished Attempts, summaries that are not finished computing, and late writes never appear in any read command. So every result you see mid-run is complete, and you never read half-written data. ## Get this run's Run ID when it ends When `niceeval exp` ends, the terminal's `NEXT` panel lists the Runs it created and gives two commands for reading them: ```sh theme={null} pnpm exec niceeval show --run pnpm exec niceeval view --run ``` With `--json`, the last line is this run's receipt. Its `createdRunIds` lists the new Runs, and `publicationCutoff` marks how far publication had reached when this run ended. Do not parse `publicationCutoff`; pass it to later commands as is. Scripts and CI first run `niceeval query discover` to find the available queries, then send a complete request. ## The browser page does not jump to new results by itself When `niceeval view` opens, it reads the results published at that moment and stays on them. When new Attempts publish later, the page prompts you to refresh. Only after you confirm does it switch to the latest results. This way the page does not change in the middle of a comparison. The Runner never hands the database, the working directory used during execution, or unpublished records to read commands. Which results to select, what is missing, which evidence to use, and how to compare are all decided by NiceEval's fixed built-in queries. See [How run results flow](/docs/explanation/results-flow). # Integration tiers: three levels of investment, three sets of capabilities Source: https://niceeval.com/docs/explanation/tier Integration has three tiers based on where the Adapter connects and what additional observability data it receives: Tier 1 connects only send, Tier 2 adds OTel, and Tier 3 changes the application to expose Experiment flags. Learn what each tier provides and when to move up. Connecting [NiceEval](https://niceeval.com/) is not an all-or-nothing project. Based on where the Adapter connects and what additional observability data it receives, integration has three tiers. Each tier adds one kind of investment to the previous tier and provides a new set of capabilities; every eval case you have already written keeps working unchanged. ## Tier 1: connect only `send` You do not change a line of application code. Integration is a single Adapter file whose `send` communicates through the same interface your user-facing frontend already uses. **The full assertion set is available at this tier.** That includes text assertions and Judge checks, structured-output validation, multi-turn and session isolation, HITL approval flows, tool assertions from event mapping, and usage assertions from the usage returned by `send`. Every verdict comes from the `Turn` returned by `send`; the next two tiers add no assertions. If the application interface exposes model selection, **model comparison** Experiments also belong here: the Experiment's `model` reaches the Adapter through `ctx.model` and only needs to be forwarded with the request. Most teams start here, and many need nothing more. ## Tier 2: `send` + OTel Use the same `send` and event mapping, but have the application also send its OTel spans to [NiceEval](https://niceeval.com/). If the application is already instrumented with AI SDK telemetry, LangGraph, OpenLLMetry / OpenInference, or its own `gen_ai` spans, this requires no application change. Otherwise, add generic OTel initialization. That is observability work, not a change customized for eval cases. This tier provides **observability**: the call waterfall in `niceeval view`. Each model call and tool execution inside the application appears in a per-turn timeline with its latency and tokens. Assertions are unaffected: spans go only into the waterfall, not the event stream or assertions. See [OTel Integration](/docs/tutorials/connect-otel) for the path. ## Tier 3: application changes + Experiment flags To evaluate a **feature A/B test** such as “which Prompt, tool set, or feature toggle works better?”, the comparison point is inside the application, where the first two tiers cannot reach it. This tier changes application code to expose variants as externally selectable configuration. An Experiment's `flags` pass through `ctx.flags` to the Adapter; the Adapter sends them to the application with the request, using an HTTP header, request-body field, or environment variable. The application chooses the variant from those parameters. What changes is the application, not the integration surface. The Adapter stays the same and still calls the application only through its external interface. ## Non-intrusive is the baseline for the first two tiers The first two tiers are **non-intrusive**. You start the application in its normal way, such as `pnpm start` or a deployment wherever you run it. The eval side does not spawn the application process or open another port. The Adapter only communicates with **the same interface your user-facing frontend already uses**, such as an HTTP endpoint or SSE stream. If it cannot connect, it reports a clear “start the application first” error. The only difference is observability data: Tier 1 has only the events returned by `send`, while Tier 2 has an additional copy of spans sent to [NiceEval](https://niceeval.com/), so the waterfall appears. ## How to move up The three tiers are progressive rather than mutually exclusive. Start at Tier 1 for a baseline and model comparisons. Move to Tier 2 when you need the call waterfall. Move to Tier 3 when you need to compare variants inside the application. Each upgrade only adds something to the Adapter or application. See [Experiment](/docs/explanation/experiment) for how to organize comparisons on the Experiment side. ## Related reading * [Connect Your Agent](/docs/tutorials/connect-your-agent) — The integration overview: minimal integration and parameter channels. * [Adapter](/docs/explanation/adapter) — The contract itself: what `send` receives and returns, and how `ctx.model` / `ctx.telemetry` / `ctx.flags` appear at each tier. * [OTel Integration](/docs/tutorials/connect-otel) — The full Tier 2 path. * [Experiment](/docs/explanation/experiment) — How to declare model / flags comparisons in an Experiment. # What is NiceEval Source: https://niceeval.com/docs/introduction Write TypeScript evals for AI Agents and applications: assert tool calls, replies, code changes, and business outcomes, compare configurations, and investigate failures. You revised a prompt, switched models, wrote a new Skill for Claude Code, or tuned an NPC's prompt in a game. Did it get better? A few manual trials cannot reliably answer that. An Agent's responses vary, and one lucky answer proves little. Fixing one case can quietly break another. NiceEval helps answer that question. It focuses on Agents and also evaluates any LLM-powered application. You define success in TypeScript, and NiceEval handles the rest: * Connect to the subject under test and send it tasks. * Repeat runs as needed and score each one. * Save conversations, tool calls, file changes, duration, and cost for every run. * Compare configurations in the terminal or browser, then inspect a specific failure to see what happened. Everything runs on your own machine and in your CI. No account required. ## What an eval looks like This eval checks that a weather assistant actually calls `get_weather` when asked about the weather, instead of making up an answer. ```ts theme={null} // evals/weather-tool.eval.ts import { defineEval, defineJudge } from "niceeval"; import { includes } from "niceeval/expect"; const groundedAnswer = defineJudge({ name: "grounded-weather-answer", rubric: "Assess whether the answer gives specific weather information based on the question instead of refusing or answering vaguely.", }); export default defineEval({ description: "Call the weather tool and answer based on its result", judge: groundedAnswer, async test(t) { const turn = await t.send("What's the weather like in Beijing today?"); turn.succeeded().label("Turn succeeded"); t.calledTool("get_weather").label("Calls the weather tool"); t.check(turn.message, includes("weather")).label("Describes the weather"); const second = await t.send("And Shanghai tomorrow?"); t.check(second.message, includes("Shanghai")).label("Second turn follows the context"); t.judge({ question: turn.input, answer: turn.message }, groundedAnswer).gate(0.7) .label("Answer is grounded"); }, }); ``` Use deterministic rules for facts such as which tool was called or whether a reply contains a word. For open-ended quality, such as whether an answer is grounded, use a Judge model to score it. A score below 0.7 fails this check. The Agent and model to run belong in an Experiment, separate from the eval. You can therefore use the same evals to compare two models or two prompt versions. ## What evals are for Evals look like unit tests when you write them and work like experiments when you run them. The same set of evals can serve several purposes: * **Prevent regressions**: rerun after changing prompts, models, or dependencies and check whether the pass rate drops. See [Rerun affected evals after changes](/docs/tutorials/rerun-and-cache). * **Compare alternatives**: run the same evals against two models, two prompts, or with and without a Skill, then compare each case. See [Write an experiment](/docs/tutorials/write-experiment) and [Compare runs](/docs/tutorials/compare-runs). * **Gate CI**: make a PR fail when its evals fail. See [CI integration](/docs/tutorials/ci-integration). * **Build coverage over time**: turn failures from real use into new evals so every future change checks them. ## What you can evaluate Use AI SDK, LangGraph, Pi, or your own Agent loop, in any language. If it exposes a callable API, you can connect it with an Adapter. Run Claude Code or Codex in a Sandbox with a real repository and task, then score it with the project's own tests. Measure the effect of Skills, Plugins, or memory. If your application exposes operations such as posting, replying, or NPC actions, call them directly from evals and check structured results and world state. For applications that are not conversational Agents, evals call the application's own methods. This eval comes from the AI social application in [`examples/zh/llm-x`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/llm-x): ```ts theme={null} // evals/social-journey.eval.ts — evaluates an AI-powered social application export default x.defineEval({ description: "AI characters respond to posts and replies, and the social world stays consistent", async test(t) { const initial = await t.visitDiscoveryPage(); const post = await t.post({ intent: "Invite everyone to photograph the city at night", withImage: true }); t.check(post, authoredPost(initial.viewerId)).label("Post belongs to the current player"); const reply = await t.reply({ postId: post.id, intent: "Add that we meet at the riverside path entrance" }); const responses = await t.waitForReplies(reply.id); t.check(responses.length, greaterThan(0)).label("AI characters respond"); t.check(worldMaterial(await t.refreshFeed()), coherentSocialWorld()).label("Social relationships survive a refresh"); }, }); ``` There are two ways to connect the subject under test: NiceEval calls your Agent or application API through an Adapter. Docker is not required. Use this for conversational Agents and applications that expose business operations. Coding Agents need a real file system to edit code and run commands. NiceEval creates an isolated Sandbox, then starts the Agent inside it. ## How NiceEval differs from DeepEval, LangFuse, and Braintrust DeepEval is a mature Python evaluation framework with a broad library of metrics. NiceEval makes different choices: * **Native TypeScript**: evals, Adapters, and Experiments are typed TypeScript code. * **Assert what happens along the way**: an Agent may need several conversation turns, tool calls, file reads, and code changes to complete a task. The final answer is only part of it. NiceEval asserts these facts directly, without requiring a golden dataset first. * **Score in a real environment**: coding Agents run in a Sandbox and are scored using the project's own tests and file changes, beyond the reply alone. * **Built for controlled experiments**: separate evals from Experiments to compare models, prompts, and extensions with the same cases. Observability platforms such as LangFuse and Braintrust answer “What happened in production?” Evals answer “Is this behavior good enough?” NiceEval focuses on the latter and the local cycle of writing evals, running them, reviewing results, and improving the Agent. They can coexist: keep using those platforms for production traces and send NiceEval results to Braintrust. ## Terms you will see often | Term | Meaning | | - | - | | [Eval](/docs/explanation/evals) | A test in `evals/*.eval.ts` that defines what to send and what counts as success. | | [Experiment](/docs/explanation/experiment) | A run configuration that chooses the Agent, model, and number of runs. | | [Adapter](/docs/explanation/adapter) | Code that connects to the subject under test, sends requests, and translates replies into events NiceEval can read. | | [Sandbox](/docs/tutorials/sandbox-providers) | An isolated runtime environment. Coding Agents need it; directly connected applications do not. | | Attempt | One execution of an eval. Running the same eval five times produces five Attempts. | Other terms are explained where they first appear. ## Next The [Quickstart](/docs/tutorials/quickstart) walks you through running your first eval and seeing results in the terminal and browser in about ten minutes. # Built-in Agent Capabilities Reference Source: https://niceeval.com/docs/reference/builtin-agents What NiceEval's built-in claude-code, codex, and bub Adapters can do, which assertions they support, and their known limits. The three built-in Sandbox Agents—`claude-code`, `codex`, and `bub`—do not all provide the same capabilities. There is no capability-declaration layer: construction and actual behavior prove them. See the [Capabilities reference](/docs/reference/capabilities). This page inventories what each built-in Agent provides, plus two built-ins for AI SDK applications: the non-intrusive HTTP Adapter `uiMessageStreamAgent`, including HITL, and the result converter `turnFromAiSdk`. ## Capability overview | Capability | Assertion / API that corresponds to it | | - | - | | Send and receive messages (base contract) | `t.send()` (can be called multiple times), `t.reply`, `t.check(turn.data, equals(...))` / `matches(...)`, and status-based `t.succeeded()` | | Event-stream completeness | Scoped assertions including `calledTool` / `usedNoTools` / `maxToolCalls` / `noFailedActions` / `event`; `toolOrder` / `eventOrder` accept matchers only on a Turn or Session. For messages, use `t.check(turn.message, includes(...))` or `t.check(turn.message, pattern(...))`. **A negative assertion such as `notCalledTool` is trustworthy only with a complete event stream.** | | Session continuation | Cross-turn memory assertions and session isolation with `t.newSession()` | | HITL | `t.check(turn.status, equals("waiting"))`, `t.requireInputRequest()`, `t.respond()` / `t.respondAll()` | | `tracing` | Trace decoding and the call waterfall in `niceeval view` | ## What each built-in Agent provides | Agent | Send and receive | Event stream | Session continuation | HITL | Tracing | Notes | | - | - | - | - | - | - | - | | `claude-code` | ✅ | ✅ | ✅ (`claude --resume `) | ❌ | ✅ (`http/protobuf` → OTLP, beta Flag) | The built-in parser emits raw `compaction` events automatically, read by Reports, diagnostics, and an ordinary `check(events, match)` | | `codex` | ✅ | ✅ | ✅ (`codex exec resume `) | ❌ | ✅ (`http/json` → OTLP) | The built-in parser emits `compaction` events. | | `bub` | ✅ | ✅ | ✅ (`--session-id` + tape continuation) | ❌ | ✅ (`http/protobuf` → OTLP) | The built-in parser emits `compaction` events. | All three use `defineSandboxAgent` (`Agent.kind` is always `"sandbox"`), so filesystem assertions and file I/O and command execution—including `t.sandbox.fileChanged()` and diff assertions—work with all three Agents regardless of the table. The three built-in Sandbox Agents **do not support HITL**. `send` returns only `"completed"` / `"failed"`; it never returns `"waiting"` or emits `input.requested`. When you need `t.respond()` / `t.requireInputRequest()`: if the system under test is an AI SDK `useChat` backend, use the built-in `uiMessageStreamAgent` below, which natively maps v7 tool approval to HITL. For another system under test, write an Adapter that implements a `waiting` status, `input.requested` event, and resume handoff. [`examples/zh/tier1/pi-sdk`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/pi-sdk) and [`examples/zh/tier1/claude-sdk`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/claude-sdk) both provide ready-made references for manual HITL. ## Details by Agent ### claude-code * Connection: spawns `claude --print --dangerously-skip-permissions` in the Sandbox and reads the newest transcript at `~/.claude/projects/**/*.jsonl`. * Session continuation: when `ctx.session.id` has a value, appends `--resume `; the session ID decoded from the transcript is written back with `ctx.session.capture()`. * Authentication: `ANTHROPIC_API_KEY`, optionally `ANTHROPIC_BASE_URL`. See `ClaudeCodeConfig` below for options. * `tracing` uses the claude CLI's native beta OTLP trace spans over `http/protobuf`. Set `CLAUDE_CODE_ENABLE_TELEMETRY` and `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` in the environment—the explicit beta opt-in—to give the endpoint to the CLI. Trace decoding displays an interaction / llm\_request / tool hierarchy waterfall. ### codex * Connection: runs `codex exec --json` in the Sandbox, or `codex exec resume --json` for continuation, and uses stdout JSONL as the transcript. The command includes `--dangerously-bypass-approvals-and-sandbox` and `--dangerously-bypass-hook-trust`: nobody can answer Codex's interactive confirmations in a Sandbox, so a Hook installed by a Plugin or `postSetup` can take effect without interactive trust. * Authentication: `CODEX_API_KEY`, and optionally `CODEX_BASE_URL` for an OpenAI-compatible proxy. See `CodexConfig` below for options. * `tracing` is configured through `[otel.trace_exporter.otlp-http]` in `~/.codex/config.toml`, over `http/json`. ### bub * Connection: runs `bub run` with `--session-id` in the Sandbox and reads the tape at `~/.bub/tapes/.jsonl` as the transcript. * Authentication: `BUB_API_KEY` plus `BUB_API_BASE` for an OpenAI-compatible proxy. See `BubConfig` below for options. * `tracing` is injected through environment variables such as `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, over `http/protobuf`. * Installation uses `uv tool install`, not an npm package. The first install creates a checkpoint cache to speed later Sandboxes. ## Configuration for the three built-in Sandbox Agents ### `ClaudeCodeConfig` #### `apiKey` ```ts theme={null} apiKey?: string; ``` The Anthropic API key. When omitted, read `ANTHROPIC_API_KEY` from the environment. #### `baseUrl` ```ts theme={null} baseUrl?: string; ``` A custom API base URL, for a proxy or internal endpoint. When omitted, read `ANTHROPIC_BASE_URL` from the environment. When neither exists, use the Anthropic official endpoint, the claude CLI default behavior. #### `env` ```ts theme={null} env?: Readonly>; ``` Extra environment variables for the Claude Code process. Values remain only at runtime and do not enter result carry identity. A non-sensitive value that changes behavior must also appear in Experiment Flags or the owning Plugin's identity. #### `maxTurns` ```ts theme={null} maxTurns?: number; ``` The maximum number of tool-use turns, passed to `--max-turns`. It limits Eval cost; when omitted, the CLI native default is unlimited. #### `mcpServers` ```ts theme={null} mcpServers?: readonly McpServer[]; ``` Extra MCP servers, written to user-level `~/.claude.json` during each Sandbox setup. The stdio form writes `command`, optionally with `args` / `env`. The Streamable HTTP form writes `url`, optionally with `headers`, copied verbatim into request headers, as a `{ "type": "http", "url": …, "headers": … }` entry. #### `skills` ```ts theme={null} skills?: readonly SkillSpec[]; ``` Skills installed in the Sandbox: a local directory or file, or a Repo with a pinnable ref and optional enabled subset. They go to project-level `.claude/skills//`, where the claude CLI discovers them natively. #### `plugins` ```ts theme={null} plugins?: readonly ClaudeCodePluginSpec[]; ``` Native Claude Code Plugins: first connect a Marketplace, then install the named Plugin from it. #### `settingsFile` ```ts theme={null} settingsFile?: string; ``` The path to a complete official-format Claude Code `settings.json` in the local project. It resolves relative to the project root that runs niceeval—the directory containing `niceeval.config.ts`—not inside the Sandbox. It accepts only a relative path within the project root. A path containing `..`, an absolute path, a `~` path, or a symlink that resolves outside the project root errors during setup. Its original bytes are uploaded verbatim as the otherwise-empty user-level `~/.claude/settings.json` in the Sandbox. It does not inherit host configuration, concatenate, or reserialize. Reserved keys `model` and `env` cause a setup error when present. The manifest records only the project-relative path and byte SHA-256, not the contents. #### `postSetup` ```ts theme={null} postSetup?: readonly SandboxCommand[]; ``` User Hooks run in array order after installation. They reuse SandboxCommand's narrow context and run only after settings are written, MCP attached, Skills and Plugins installed, and the manifest written. They suit process actions that can run only after installed artifacts are ready, such as a Plugin's setup script. A throw counts as an infrastructure error (`attempt errored`). See Post-install scripts in `docs/feature/adapters/library/coding-agent-extensions.md`. #### `preTeardown` ```ts theme={null} preTeardown?: readonly SandboxCommand[]; ``` The cleanup Hook paired with `postSetup`. It uses `postSetup`'s reverse order and runs before the Agent's own teardown step: a LIFO mirror—`postSetup` runs after Agent installation, while `preTeardown` runs before Agent cleanup. It runs only if execution reached the `postSetup` point. A throw counts as an infrastructure error, and the teardown phase closes it with a teardown-failed diagnostic. See Post-install scripts in `docs/feature/adapters/library/coding-agent-extensions.md`. ### `CodexConfig` #### `apiKey` ```ts theme={null} apiKey?: string; ``` Proxy / OpenAI API key. Falls back to the `CODEX_API_KEY` env var if omitted. #### `baseUrl` ```ts theme={null} baseUrl?: string; ``` OpenAI-compatible proxy base URL (e.g. `https://s2a.example.com/v1`). Falls back to the `CODEX_BASE_URL` env var if omitted. #### `env` ```ts theme={null} env?: Readonly>; ``` Extra environment variables injected into every Codex CLI process. The first `codex exec` and continuing `codex exec resume` use the same declaration; lifecycle Hooks started by Codex, dynamic MCP headers, and command subprocesses all inherit it. Values enter only through Sandbox command options: they are not concatenated into shell text or written to the setup manifest, and are all redacted as potential sensitive values from timing, execution, and error evidence. `CODEX_API_KEY` still comes from `apiKey` or the same host environment variable; the Adapter's authentication value overrides a same-named key here. An environment value does not enter carry identity. A non-sensitive value that changes behavior must also be declared in Experiment Flags or the owning Plugin identity; rotating credentials alone does not invalidate old results. `PATH` is Sandbox-managed and cannot be declared here. Its presence errors at factory construction; use the Sandbox factory's `pathPrepend` instead. See `docs/feature/sandbox/library.md`. #### `mcpServers` ```ts theme={null} mcpServers?: readonly McpServer[]; ``` Extra MCP servers, appended to `~/.codex/config.toml` during each Sandbox setup. The stdio form (`command` / `args` / `env`) writes a `command` line in `[mcp_servers.]`. The Streamable HTTP form (`url` / `headers`) writes the `url` line and headers in the `[mcp_servers..http_headers]` subtable. #### `skills` ```ts theme={null} skills?: readonly SkillSpec[]; ``` Skills installed in the Sandbox: a local directory or file, or a Repo with a pinnable ref and optional enabled subset. They go to `.agents/skills//`, and a discovery instruction is written into AGENTS.md. Codex has no native Skill tool like Claude Code, so installing the file alone does not make it read it. See `memory/codex-no-native-skill-tool.md`. #### `plugins` ```ts theme={null} plugins?: readonly CodexPluginSpec[]; ``` Native Codex Plugins: first connect a Marketplace, then install the named Plugin from it. #### `configFile` ```ts theme={null} configFile?: string; ``` The path to a complete official-TOML Codex `config.toml` in the local project. It resolves relative to the project root that runs niceeval—the directory containing `niceeval.config.ts`—not inside the Sandbox. It accepts only a relative path within the project root. A path containing `..`, an absolute path, a `~` path, or a symlink that resolves outside the project root errors during setup. Its original bytes are uploaded verbatim into the otherwise-empty user-level `~/.codex/config.toml` in the Sandbox. It does not inherit host configuration or parse and rewrite it. Reserved keys `model`, `model_provider`, `model_providers`, `model_reasoning_effort`, `mcp_servers`, and `otel` cause a setup error when present. The manifest records only the project-relative path and byte SHA-256, not the contents. #### `postSetup` ```ts theme={null} postSetup?: readonly SandboxCommand[]; ``` User Hooks run in array order after installation. They reuse SandboxCommand's narrow context and run only after the main configuration is written, MCP attached, Skills and Plugins installed, and the manifest written. They suit process actions that can run only after installed artifacts are ready, such as a Plugin's setup script. A throw counts as an infrastructure error (`attempt errored`). See Post-install scripts in `docs/feature/adapters/library/coding-agent-extensions.md`. #### `preTeardown` ```ts theme={null} preTeardown?: readonly SandboxCommand[]; ``` The cleanup Hook paired with `postSetup`. It uses `postSetup`'s reverse order and runs before the Agent's own teardown step: a LIFO mirror—`postSetup` runs after Agent installation, while `preTeardown` runs before Agent cleanup. It runs only if execution reached the `postSetup` point. A throw counts as an infrastructure error, and the teardown phase closes it with a teardown-failed diagnostic. See Post-install scripts in `docs/feature/adapters/library/coding-agent-extensions.md`. ### `BubConfig` #### `apiKey` ```ts theme={null} apiKey?: string; ``` API key for the OpenAI-compatible proxy. Falls back to the `BUB_API_KEY` env var if omitted. #### `apiBase` ```ts theme={null} apiBase?: string; ``` Base URL for the OpenAI-compatible proxy. Falls back to the `BUB_API_BASE` env var if omitted. #### `skills` ```ts theme={null} skills?: SkillSpec[]; ``` Skills installed in the Sandbox: a local directory or file, or a Repo with a pinnable ref and optional enabled subset. They go to `.agents/skills//`, and a discovery instruction is written into AGENTS.md. Bub has no native Skill-loading mechanism. #### `version` ```ts theme={null} version?: string; ``` Which Bub version to install, as a PyPI version such as `"0.4.0"`. When omitted, use the default version pinned by NiceEval. It is always a deterministic version and never latest: the system under test's version must be visible from Experiment configuration. #### `otelPlugin` ```ts theme={null} otelPlugin?: string; ``` The git dependency for the OTel tape-store Plugin, the source of the timeline. When omitted, use NiceEval's default pin. The Plugin and Bub must be from the same generation of the tape protocol. The default pin takes types from `bub.tape` and requires Bub ≥ 0.3.10. Earlier Plugin commits validate against republic types and pair with Bub ≤ 0.3.9. A mismatched generation does not fail installation; it rejects every span and leaves the timeline silently empty. When pinning `version` backward, pin the matching Plugin commit in the same change. #### `pythonPlugins` ```ts theme={null} pythonPlugins?: PythonPluginSpec[]; ``` Extra Python packages installed into the bub tool environment. Each Sandbox setup adds them through `uv tool install … --with `. The normalized package list enters the installation checkpoint key, so two Agent variants with different Plugin sets never reuse the same checkpoint; otherwise the second could silently receive the first variant's environment. #### `postSetup` ```ts theme={null} postSetup?: SandboxCommand[]; ``` User Hooks run in array order after installation. They reuse SandboxCommand's narrow context and run only after bub, Skills and Python packages are installed, and the manifest is written. A throw counts as an infrastructure error (`attempt errored`). See Post-install scripts in `docs/feature/adapters/library/coding-agent-extensions.md`. #### `preTeardown` ```ts theme={null} preTeardown?: SandboxCommand[]; ``` The cleanup Hook paired with `postSetup`. It uses `postSetup`'s reverse order and runs before the Agent's own teardown step: a LIFO mirror—`postSetup` runs after Agent installation, while `preTeardown` runs before Agent cleanup. It runs only if execution reached the `postSetup` point. A throw counts as an infrastructure error, and the teardown phase closes it with a teardown-failed diagnostic. See Post-install scripts in `docs/feature/adapters/library/coding-agent-extensions.md`. ## `uiMessageStreamAgent`: built-in non-intrusive Adapter for AI SDK applications, including HITL `uiMessageStreamAgent`, exported from `niceeval/adapter`, sends and receives non-intrusively against an HTTP endpoint implementing the [UI Message Stream protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol), the standard SSE for an AI SDK `useChat` backend. It only uses `fetch`; it does not import application code, so it can connect wherever the application is deployed: ```ts theme={null} import { uiMessageStreamAgent } from "niceeval/adapter"; export default uiMessageStreamAgent({ name: "my-assistant", url: "https://my-app.example.com/api/chat", body: (ctx) => ({ model: ctx.model }), // model comparison needs no change when the application supports a per-request model }); ``` Capabilities it provides for you: * **Send/receive and event stream**: SSE frames are reduced through the framework-agnostic `readUIMessageStream` reducer from the official `ai` package—the same reducer `useChat` uses internally. Tool calls, results, and message text are constructed directly from message parts. **The application does not need OTel.** * **Session continuation**: the protocol is stateless on the server and the client carries complete history. The factory stores complete `UIMessage[]` history in an Adapter-private typed slot and replays it verbatim each turn. A new session line after `t.newSession()` has not written that slot yet. * **HITL**: AI SDK v7 tool approval—a tool with `needsApproval: true`—maps natively. When a part stops at `approval-requested`, that Turn has `status: "waiting"` plus `input.requested`. `t.respond("approve" / "deny")` translates to `approval-responded`, rewrites that part in place, and resends `messages` to continue server execution. That is the same protocol behavior as a real frontend's `addToolApprovalResponse()` plus `sendMessage()`. There is no separate approval endpoint. * **Rejection**: an invocation enters the event stream with `rejected` and, by default, a "do not retry" reason that `denyReason` can override. Without it, models often repeat the exact call. * **Usage and waterfall**: UI Message Stream frames have no usage, so usage assertions such as `t.maxTokens` have no data by default on this built-in. An application that puts usage in message metadata is adding its own protocol extension. The waterfall is separate: when the application has OTel instrumentation, such as official `@ai-sdk/otel`, send spans to [NiceEval](https://niceeval.com/) according to [OTel integration](/docs/tutorials/connect-otel) and `niceeval view` has the complete waterfall. Spans go only to the waterfall, not assertions. Install `ai` in the Eval project. It is an optional peer dependency because the protocol reducer comes from it. For a complete runnable example, see [`examples/zh/tier1/ai-sdk-v7`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/ai-sdk-v7). Complete `uiMessageStreamAgent(options)` parameters (`UiMessageStreamAgentOptions`): #### `name` ```ts theme={null} name?: string; ``` The Agent name used for Report and result-aggregation identity. Defaults to `"ui-message-stream"`. #### `url` ```ts theme={null} url: string | ((ctx: AgentContext) => string | Promise); ``` The chat endpoint of the application under test. Use its full URL wherever the application is deployed. The function form resolves once per turn. #### `headers` ```ts theme={null} headers?: globalThis.Record | ((ctx: AgentContext) => globalThis.Record); ``` Additional request headers, such as authentication. `ctx.telemetry.headers` for `traceparent` always merges in automatically. #### `body` ```ts theme={null} body?: (ctx: AgentContext) => globalThis.Record; ``` Fields to merge into the request body in addition to `messages`, such as `(ctx) => ({ model: ctx.model })`. Fields whose value is `undefined` are automatically omitted during serialization. #### `projectToolCommand` ```ts theme={null} projectToolCommand?: (tool: Readonly<{ name: string; input: JsonValue }>) => CommandProjection | undefined; ``` Lets the endpoint owner classify every logical tool call as command or not-command. An unknown call returns `undefined` and leaves actions coverage partial. NiceEval does not guess from a name or input. #### `denyReason` ```ts theme={null} denyReason?: string; ``` The reason sent with `approval-responded` when approval is denied. The application and SDK make it tool-result text that the model sees. A clear "do not retry" message measurably lowers the chance that a model repeats the same call. #### `settleMs` ```ts theme={null} settleMs?: number; ``` Milliseconds to wait after the stream ends before returning, giving application observability exporters such as BatchSpanProcessor time to export. #### `tracing` ```ts theme={null} tracing?: AgentTracing; ``` How an application with OTel delivers spans to the endpoint, for the waterfall. The event stream does not depend on it. #### `spanMapper` ```ts theme={null} spanMapper?: SpanMapper; ``` How an application with OTel normalizes spans, for the waterfall. The event stream does not depend on it. ## OpenAI-compatible response converters: `turnFromChatCompletion` / `turnFromResponses` `turnFromChatCompletion(res)` and `turnFromResponses(res)`, exported from `niceeval/adapter`, convert OpenAI's two response shapes—Chat Completions and Responses—into a `Turn` with zero mapping. They are not limited to official OpenAI: any service that declares compatibility with either protocol shape can use them. ```ts theme={null} import { completeEvidenceCoverage, defineAgent, turnFromChatCompletion } from "niceeval/adapter"; export default defineAgent({ name: "my-openai-compat-agent", evidenceCoverage: completeEvidenceCoverage, async send(input) { const res = await client.chat.completions.create({ messages: [...], tools }); return turnFromChatCompletion(res); }, }); ``` The two shapes differ in how trustworthy a negative assertion such as `notCalledTool` is. Chat Completions does not promise that its response is a complete process—the application might run a whole tool loop server-side and return only the final answer—so a negative assertion means only "not seen," not "did not happen." The Responses protocol guarantees that its `output` array records everything the model decided to do this turn, including every `function_call`, so negative assertions are trustworthy. The `Turn` shapes output by the two converters are the same; this distinction only affects how you interpret negative assertions. ## SDK event-stream converters: `createClaudeSdkEventStream` / `createPiAgentEventStream` / `createCodexThreadEventStream` Each Agent SDK defines its own streaming protocol, rather than a private application format. `niceeval/adapter` provides converters from native frames to standard events. When writing a non-intrusive Adapter yourself, only provide the application-interface and approval-interface addresses. `driveFrameStream`, described in the next section, reads results frame by frame, so you do not write another `for` loop: ```ts theme={null} import { sseJsonFrames, createClaudeSdkEventStream, driveFrameStream } from "niceeval/adapter"; const frames = sseJsonFrames(res.body); // standard SSE → JSON for each frame const stream = createClaudeSdkEventStream(); // SDKMessage → standard events return driveFrameStream(frames, stream, ctx); // feed every frame to stream.add(), aggregate into a Turn ``` * **`createClaudeSdkEventStream`** (`SDKMessage`): `assistant` text/tool\_use blocks, `user` tool\_result blocks, `system` / `permission_denied` (→ `rejected`), and `result` usage. `markRejected()` records a rejected invocation. * **`createPiAgentEventStream`** (pi-agent-core `AgentEvent`): `message_end` text/thinking/usage and paired `tool_execution_start/end` tools. * **`createCodexThreadEventStream`** (Codex SDK `ThreadEvent`): messages from `agent_message` / `reasoning`. Tool items are `command_execution` / `mcp_tool_call` / `file_change` / `web_search`, each mapped to paired tool `operation.started` + `operation.finished`. It includes `turn.completed` usage and error frames. For a waterfall, use native Codex CLI OTLP—`[otel]` in config.toml—to send spans to [NiceEval](https://niceeval.com/), then normalize with official `mapCodexSpans`. Runnable reference implementations are in [`examples/zh/tier1`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1). The Claude SDK, pi SDK, and Codex SDK Adapters there each consist of a converter, `driveFrameStream`, and application-interface configuration. ## General assembly helpers: `driveFrameStream` / `deltaStream` + `ctx.session` Only three pieces of a hand-written send are truly independent: transport (how to send), reduction (raw data → events, handled by the converters above), and orchestration (session continuation plus HITL pause and resume). The third has nothing to do with a particular protocol; it is a pure control-flow pattern. For server history, use `ctx.session.id` / `capture`. For client history and paused HITL state, declare an Adapter-private typed slot with `createSessionSlot()`, then use `ctx.session.get` / `set` / `take` to access it. See `AgentSession` in [Adapter concepts](/docs/explanation/adapter#context-agentcontext). Only two official helpers actually reuse across protocols: | Helper | What it solves | | - | - | | `driveFrameStream(cursor, reducer, ctx, onFrame?)` | Feeds frames to a reducer one at a time, handles transport frames, and detects a HITL pause signal, collecting the loop into one function. On pause, `cursor` stays open. Use `ctx.session.set(slot, cursor)` in `onFrame` to retain it, then `ctx.session.take(slot)` on the response turn to continue reading rather than send another request. | | `deltaStream(spec)` | A general accumulator for token-by-token or argument-by-argument deltas. Use it for protocols with no complete frame, such as raw streaming OpenAI/Anthropic APIs or a hand-written token-by-token backend. Declare which operation a frame belongs to—text delta, tool-argument delta, or finishing—and it handles concatenation timing. | ```ts theme={null} const pendingSlot = createSessionSlot("my-adapter/pending"); // On entry to send, first check for retained state from a paused HITL turn. const pending = ctx.session.take(pendingSlot); if (pending) { // …match decisions against input.responses and return approval to the application… return driveFrameStream(pending.cursor, pending.stream, ctx, onFrame); } // Normal turn: session continuation uses ctx.session.id / capture; retain the cursor in a typed slot on pause. return driveFrameStream(cursor, stream, ctx, (frame, derived) => { ctx.session.capture(stream.sessionId); const gated = derived.find((event) => event.type === "operation.started" && event.operation.kind === "tool" && event.operation.name === GATED_TOOL_NAME ); if (gated?.type === "operation.started") { ctx.session.set(pendingSlot, { cursor, stream, toolUseId: gated.operationId }); return { pause: { id: gated.operationId, action: GATED_TOOL_NAME, options: [{ id: "approve" }, { id: "deny" }] } }; } }); ``` The three Tier 1 examples—claude-sdk and pi-sdk with `driveFrameStream` plus a typed slot, and codex-sdk with `driveFrameStream` only—have already been rewritten around these helpers. An Adapter is left only with transport and the decision in `onFrame` about whether a frame needs extra handling; it has no hand-written loop or module-level Map. ## `turnFromAiSdk`: AI SDK result → event-stream converter `turnFromAiSdk`, exported from `niceeval/adapter`, is for your own Adapter, such as direct server construction for an HTTP web Agent. See `examples/zh/ai-sdk/`. It maps AI SDK `generateText` / `streamText` results into `{ events, usage, status }`. It pairs `toolCallId` exactly, preserves order, maps `tool-error` to a failed tool `operation.finished`, and aggregates usage across v4 / v5 / v7 field drift. Spread it directly into a `Turn`: ```ts theme={null} import { completeEvidenceCoverage, defineAgent, turnFromAiSdk } from "niceeval/adapter"; export default defineAgent({ name: "my-ai-sdk-agent", evidenceCoverage: completeEvidenceCoverage, async send(input) { const result = await generateText({ model, tools, prompt: input.text }); return { ...turnFromAiSdk(result), data: result.text }; }, }); ``` The converter supplies `status`: it is `"waiting"` when a tool approval awaits a person, with an `input.requested` event; otherwise it is `"completed"`. Session continuation for multiple-turn resume, returning an HITL decision, and tracing depend on how you write your own `send`. ## How to choose * For an AI SDK application with a `useChat` backend: use the built-in `uiMessageStreamAgent` for non-intrusive, zero-mapping integration, including HITL. * For another Agent system, such as an HTTP / gRPC service: integrate non-intrusively and write event mapping. The official converters cover most of it. The five examples under [`examples/zh/tier1`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1) all take this route. If the application already has OTel, connect it too for an extra waterfall. * For a call waterfall—the trace in `niceeval view`: all three `claude-code` / `codex` / `bub` send OTLP themselves. claude-code uses CLI beta telemetry, whose spans have only structure and timing. A self-written Adapter only needs to declare `tracing`; see the OTel guide. * For human approval or multi-step confirmation (HITL): `uiMessageStreamAgent` provides it natively through AI SDK v7 tool approval. The three Sandbox Agents do not. For another system, write an Adapter and compose it with `driveFrameStream` plus a typed slot; Tier 1 pi-sdk / claude-sdk have ready patterns. * To run a coding Agent that changes code, inspect diff, and evaluate tool calls: use `claude-code` / `codex` / `bub` for send/receive, event stream, session continuation, workspace, and Sandbox. * When the backend protocol is your own and no official SDK converter applies, do not write send from zero. Choose a reduction shape: a small mapping for complete frames or `deltaStream` for token deltas. Use `ctx.session.id` / `capture` for server session continuation, a typed slot for client history or HITL state, and compose them into send. ## Related reading * [Connect your Agent](/docs/tutorials/connect-your-agent) — how to implement each capability and which assertion it supports when writing an Adapter. * [Sandbox Agent](/docs/tutorials/sandbox-agent) — run a built-in Sandbox Agent and write your own. * [Adapter Reference](/docs/reference/define-agent) — complete parameters for `defineAgent` / `defineSandboxAgent`. * [OTel integration](/docs/tutorials/connect-otel) — send application spans to [NiceEval](https://niceeval.com/) to get the call waterfall in `niceeval view`. # Capabilities and Evidence Coverage Reference Source: https://niceeval.com/docs/reference/capabilities How an Agent's construction, runtime behavior, and required six-channel evidenceCoverage declaration together determine what NiceEval assertions can trust. Capabilities answer two different questions: 1. **What can the Runner construct or call?** An Agent's construction and runtime behavior provide Sandbox access, tracing, session continuation, and HITL. 2. **What conclusions can assertions trust?** Every Agent must use `evidenceCoverage` to declare the completeness of six evidence channels. There is no separate `capabilities` field on `defineAgent` / `defineSandboxAgent`. That does not mean "nothing needs to be declared": `evidenceCoverage` is required on both definitions so missing evidence never becomes an ambiguous fourth state. ## Construction and runtime capabilities | Capability | How to obtain it | Effect on evidence | | - | - | - | | `t.sandbox` / `t.sandbox.fileChanged()` and other filesystem assertions | Construct with `defineSandboxAgent` (`Agent.kind` is always `"sandbox"`) | Sandbox-result assertions consume the Attempt's agent-attributed diff; they do not correspond to an `evidenceCoverage` channel | | `tracing` (OTLP receiver → `niceeval view` waterfall) | Configure `tracing` for a CLI-style Agent. Configure a fixed `telemetry` port for a long-running application | Spans go only to the timing waterfall; they cannot fill a gap in behavior evidence | | Cross-turn continuation and `t.newSession()` isolation | Continue the backend session with `ctx.session.id` / `capture`, or save typed adapter-private history with `ctx.session.get` / `set` | The session connection determines actual behavior; do not infer it from an evidence bit | | HITL (`t.respond()`) | `send` returns `status: "waiting"` and `input.requested` | The next response is matched by structured request ID | | Compaction visibility | An official parser or complete manual mapping emits a `compaction` event | Raw events go to reports and diagnostics; they can also be the subject of an ordinary `check(events, match)` | ## The required six-channel declaration Both `defineAgent` and `defineSandboxAgent` require an `EvidenceCoverage`: ```ts theme={null} interface EvidenceCoverage { readonly events: EvidenceCoverageEntry; readonly actions: EvidenceCoverageEntry; readonly messages: EvidenceCoverageEntry; readonly usage: EvidenceCoverageEntry; readonly status: EvidenceCoverageEntry; readonly data: EvidenceCoverageEntry; } type EvidenceCoverageEntry = | { readonly status: "complete"; readonly reason?: never } | { readonly status: "partial" | "unavailable"; readonly reason: string; }; ``` Use `completeEvidenceCoverage` only when the Adapter really captures every channel completely. An official converter provides its coverage declaration for the protocol it normalizes. A manual mapping must state all six channels honestly: ```ts theme={null} import type { EvidenceCoverage } from "niceeval/adapter"; const evidenceCoverage = { events: { status: "partial", reason: "the endpoint emits final events only" }, actions: { status: "unavailable", reason: "the endpoint does not expose tool lifecycles" }, messages: { status: "complete" }, usage: { status: "unavailable", reason: "the endpoint does not return token usage" }, status: { status: "complete" }, data: { status: "complete" }, } satisfies EvidenceCoverage; ``` `Turn.evidenceCoverage` is an optional per-turn **downgrade**. List only channels that are worse for that turn than the Agent default, such as when a stream ends before usage arrives. Omitted channels inherit the Agent declaration. A Turn cannot upgrade the default coverage. ## How coverage changes assertion results Coverage prevents "not collected" from looking like "confirmed not to have happened": | Check | Required channel is complete | Required channel is partial / unavailable | | - | - | - | | A positive assertion finds matching evidence | `passed` | `passed` — evidence that exists is still valid | | A positive assertion finds no match | `failed` | `unavailable` — a collection gap cannot prove the Agent did not act | | A negative assertion such as `notCalledTool()` / `notEvent()` | Evaluated normally | `unavailable` | | An upper-bound assertion such as `maxTokens()` / `maxCost()` | Evaluated normally | `unavailable` | An `unavailable` assertion is always persisted with a machine-readable reason. It is never silently discarded or folded into a pass: * A non-optional unavailable assertion makes the Attempt `errored`. * An assertion explicitly chained with `.optional()` remains visible as unavailable, but does not affect the Verdict. Judge follows the same Verdict rule, but has its own unavailable reasons: an unresolved Judge model or API key, a failed Judge call, or a response without an extractable score all produce `unavailable`. Judge is therefore not "always available." ## What happens when a manual mapping misses events If a manual mapping misses events but declares `events` or `actions` complete, negative assertions can appear to pass on an incomplete picture. That is an incorrect completeness declaration. Until the mapping covers successful, failed, rejected, and concurrent tool lifecycles, declare `partial` or `unavailable` honestly and give a reason. An official converter guarantees only the surface its protocol contract explicitly covers. OTel spans cannot repair an incomplete event mapping: spans go only to the waterfall, not assertions. ## Related reading * [Event stream reference](/docs/reference/events) — the standard event stream contract. * [Adapter concepts](/docs/explanation/adapter) — the full `ctx` / `Turn` contract. * [defineAgent and defineSandboxAgent](/docs/reference/define-agent) — Agent fields and examples. * [Assertions](/docs/explanation/assert) — unavailable, `.optional()`, and Verdict propagation. * [OTel integration](/docs/tutorials/connect-otel) — why spans feed visualization only, not assertions. * [Built-in Agent capabilities](/docs/reference/builtin-agents) — what each built-in Adapter actually captures. # CLI Source: https://niceeval.com/docs/reference/cli Read run records with query, show, and view. Run evaluations with `niceeval exp`. There are three ways to read results: scripts and CI use `niceeval query` to get JSON, people and coding agents use `niceeval show` to drill down in a terminal, and `niceeval view` is for review in a browser. All three read the same run record, `.niceeval/record.sqlite`. At the start of every read, NiceEval fixes a visibility boundary: results already written at that moment are visible, and anything written afterward does not leak into this read. Attempts already written are readable even while their Run is still running. When you pipe the output of `niceeval exp`, stdout still shows progress. Each line carries the Experiment, the Eval, and the Attempt number. The same Attempt prints at most once per second, and nothing is repeated when the content has not changed. Run directly in a terminal, it shows a live panel. Add `--json` when a script needs structured results. Pass `--experiment` repeatedly to run several Experiments at once, for example `niceeval exp --experiment baseline --experiment memory --max-concurrency 4`. These Experiments share the concurrency limit and budget within the same run. An ID given twice runs only once. If any ID does not exist, the whole command fails before execution starts. Once you use `--experiment`, positional arguments filter Evals. ## `niceeval query` ```sh theme={null} niceeval query discover niceeval query explain [--record ] --request niceeval query run [--record ] --request ``` `query` uses the versioned JSON request protocol `niceeval.query/v1`. Each fixed read question is called an operation. A successful result and a failure defined by the protocol each write exactly one JSON document to stdout. When arguments are wrong, or the process fails before it can produce a document, the error goes only to stderr. * `discover` lists the operation IDs the current version supports. It does not read the run record, so it accepts no `--record`, and it does not return the request field structure. * `explain` validates a complete request and explains which operation it will run and which requests you can send next. * `run` executes the request and returns that operation's complete result. First use `discover` to confirm which operations the current version has. Then write the request from the minimal requests on this page or in [Viewing results](/docs/tutorials/viewing-results), and validate it with `explain`. Do not guess fields, and do not build another query language from SQL, JSON paths, or formulas. Paged results carry a continuation token. The token remembers the operation, the request content, the record file being read, and the visibility boundary of this read. If any of them changes, `query` does not continue reading. It returns a restart correction, which means the caller must start again from `discover` or by resending the request. ## `niceeval show` ```sh theme={null} niceeval show [--record ] niceeval show --run ... [--record ] niceeval show --experiment ... [--record ] niceeval show @ [--record ] niceeval show @ --source [--record ] niceeval show @ --execution [--expand ] [--record ] niceeval show @ --timing [--record ] niceeval show @ --usage [--record ] niceeval show @ --diff [--record ] ``` `show` displays results in a fixed terminal format. With no selection arguments, it shows the default overview. `--run ` shows the given Run and `--experiment ` shows the given Experiment; both can be repeated. Pass rates and scores are displayed by Eval type: * Pass/fail Evals show `Verdicts` and `Pass rate`. * Score-only Evals show `Verdicts` and `Score`, with no pass rate. * When both are mixed, only the pass/fail rows show a pass rate. A "complete" score only means the score could be computed; it does not mean the Eval passed. An Eval whose Verdict is `failed` shows as failed even with a complete score, and keeps the score it earned. Only Evals with a `passed` Verdict and a complete score enter the success ranking. With a `passed` Verdict and a complete score, terminal details show `scored` and the browser shows "Scored". A zero score can also be complete: it says scoring finished, not that the goal was met, and NiceEval has no default passing line. A gate failure still shows as failed. An incomplete (`partial`) score keeps only a lower bound of the known score and does not count as a valid score. `@` selects an Attempt. A command takes at most one locator, and it cannot be used with `--run` or `--experiment`; `--run` and `--experiment` cannot be used together either. After selecting an Attempt, add any one of the options below to look at one aspect. The five options are mutually exclusive, and each requires an Attempt locator: * `--source`: the recorded source information and where each assertion sits in the source. * `--execution`: an outline of the execution, with a length limit. * `--timing`: how long each phase took. * `--usage`: usage totals and every reported usage entry. * `--diff`: file changes during the run. `--execution` output shows three things separately: the collection state, whether the reporter declared the data complete, and the paging state. Even when the current page is empty or it is the last page, a `partial` mark from the reporter and the reason for the limit are kept. * Filter by participant and event type with `--actor ` and `--type `. * When paging with `--continuation `, pass the same filters. * Expand one item directly with `--expand `. The ID must come from an `itemId`, `toolOccurrenceId`, or `commandId` in the outline, not a positional number. `--expand` works only with `--execution`, and cannot appear together with filter or paging arguments. ## `niceeval view` ```sh theme={null} niceeval view [--run ...] [--no-open] [--port ] [--json] ``` `view` opens NiceEval's built-in browser interface on your machine. Without `--run`, it opens the default overview; repeat `--run` to preselect several Runs. It accepts no positional arguments and does not load the project's own pages, themes, components, or renderers. `--no-open` only starts the server without opening a browser. `--port` sets the port; the server listens only on `127.0.0.1`. Once page assets, local access authorization, and the read endpoint are ready, the command writes an openable local address to stdout once. With `--json`, stdout switches to NDJSON in the `niceeval.view/v1` format: a `ready` event when ready and a `closed` event on close. Startup or runtime failures are reported through the exit code and stderr. `view` reads only the current project's `.niceeval/record.sqlite`. When new results are written while it is open, the page prompts you to refresh and switches to the new data only after you confirm. It has no `--record`, SQL, deployment, or sharing options, and cannot specify custom reports, pages, themes, routes, or operations. ## Choose a read path All three paths read the same results, but each owns its own layout and presentation. | Goal | Use | | - | - | | Give a script or CI stable JSON | `niceeval query discover`, then `explain` / `run` | | Let a person or coding agent view a summary of the overview, a Run, an Experiment, or an Attempt in a terminal | `niceeval show` | | Review the overview or a Run in a browser | `niceeval view` | | Read the same results on another machine | Copy `.niceeval/record.sqlite`, then point to it with `--record` | ## CLI flags The table below is generated from each command's own option definitions. Flags not in the table are rejected as unknown arguments. | Flag | Command | Type | Description | | - | - | - | - | | `--help` | `niceeval` | boolean | Print the root command index. | | `--version` | `niceeval` | boolean | Print the installed NiceEval version. | | `--tag` | `niceeval check`, `niceeval exp`, `niceeval exp list` | string | Select only Evals with this tag. | | `--help` | `niceeval check`, `niceeval exp`, `niceeval exp list`, `niceeval exp rename`, `niceeval exp --teardown`, `niceeval debug`, `niceeval accept`, `niceeval session` | boolean | Print help for this command. | | `--experiment` | `niceeval exp` | string\[] | Narrow the current project's results by Experiment selector. | | `--attempts` | `niceeval exp` | number | Run each selected Eval the given number of times. | | `--max-concurrency` | `niceeval exp` | number | Limit the number of Attempts executing at the same time. | | `--max-build-concurrency` | `niceeval exp` | number | Limit the number of Sandbox builds prepared at the same time. | | `--max-setup-prefix-concurrency` | `niceeval exp` | number | Limit the number of Sandbox setup prefixes prepared at the same time. | | `--sandbox-setup-cache` | `niceeval exp` | string | Use or bypass the Sandbox preparation cache. | | `--timeout` | `niceeval exp` | number | Set each Attempt's timeout in milliseconds. | | `--budget` | `niceeval exp` | number | Set this Invocation's budget in US dollars. | | `--junit` | `niceeval exp` | string | Write the required JUnit report. | | `--json` | `niceeval exp` | boolean | Write this command's machine document or event stream. | | `--dry` | `niceeval exp` | boolean | Plan without creating an Invocation. | | `--rerun` (alone means `failed`) / `--rerun=failed\|all` / `--rerun failed\|all` | `niceeval exp` | `failed` \| `all` | Request a rerun of failed targets or all targets. | | `--keep-sandbox` (alone means `failed`) / `--keep-sandbox=failed\|all` | `niceeval exp` | `failed` \| `all` | Keep the Sandbox of failed or all finished Attempts. | | `--early-exit` / `--no-early-exit` | `niceeval exp` | boolean | Stop the Eval's remaining Attempts after one result passes. | | `--record` | `niceeval exp`, `niceeval accept` | string | Use the given NiceEval Record root. | | `--json` | `niceeval exp list` | boolean | Write this command's machine document or event stream. | | `--run` | `niceeval exp rename` | string\[] | Select one exact Run to adopt into the target Experiment; repeating it is rejected. | | `--dry` | `niceeval exp rename` | boolean | Preview an explicit Experiment rename. | | `--json` | `niceeval exp rename` | boolean | Write this command's machine document or event stream. | | `--teardown` | `niceeval exp --teardown` | boolean | Run explicit Experiment teardown recovery. | | `--json` | `niceeval exp --teardown` | boolean | Write this command's machine document or event stream. | | `--recover-shared-state` | `niceeval exp --teardown` | string | Name the shared state key to recover explicitly. | | `--owner-token` | `niceeval exp --teardown` | string | Provide the exact shared state owner token. | | `--confirm-owner-terminated` | `niceeval exp --teardown` | boolean | Confirm that the recorded owner has terminated. | | `--confirm-remote-quiesced` | `niceeval exp --teardown` | boolean | Confirm that the remote shared state is quiescent. | | `--json` | `niceeval debug` | boolean | Write this command's machine document or event stream. | | `--run` | `niceeval accept` | string\[] | Adopt one published exact Run; repeating it is rejected. | | `--dry` | `niceeval accept` | boolean | Complete the whole-Run adoption preflight without publishing a new Run. | | `--all` | `niceeval session` | boolean | Include status entries for completed Invocations. | | `--json` | `niceeval session` | boolean | Write this command's machine document or event stream. | | `--request` | `niceeval query` | string | Read one niceeval.query/v1 request from a file or standard input. | | `--record` | `niceeval query`, `niceeval show` | string | Read an external canonical SQLite Record. | | `--help` | `niceeval query` | boolean | Print help for this command. | | `--run` | `niceeval show` | string\[] | Show one sealed Run; can be repeated. | | `--experiment` | `niceeval show` | string\[] | Show one exact Experiment; can be repeated. | | `--all` | `niceeval show` | boolean | Show Attempts hidden in the compact Results view. | | `--source` | `niceeval show` | boolean | Show an Attempt's captured source facts. | | `--execution` | `niceeval show` | boolean | Show an Attempt's bounded execution outline. | | `--actor` | `niceeval show` | string | Filter execution events by exact actor ID. | | `--type` | `niceeval show` | string | Filter execution events by exact event type. | | `--continuation` | `niceeval show` | string | Read the next execution page with the same filters. | | `--timing` | `niceeval show` | boolean | Show an Attempt's captured timing activities. | | `--usage` | `niceeval show` | boolean | Show an Attempt's captured usage totals. | | `--diff` | `niceeval show` | boolean | Show an Attempt's captured file changes. | | `--expand` | `niceeval show` | string | Expand one stable execution identity. | | `--help` | `niceeval show` | boolean | Print help for this command. | | `--run` | `niceeval view` | string\[] | Select one sealed Run; can be repeated. | | `--no-open` | `niceeval view` | boolean | Do not ask the operating system to open a browser. | | `--port` | `niceeval view` | string | Choose the loopback listening port; 0 picks one automatically. | | `--json` | `niceeval view` | boolean | Output View lifecycle NDJSON, not Inspection results. | | `--help` | `niceeval view` | boolean | Print help for this command. | | `--all` | `niceeval state migrate` | boolean | Migrate all registered first-party Service state modules. | | `--help` | `niceeval state migrate` | boolean | Print help for this command. | | `--help` | `niceeval init` | boolean | Print help for this command. | | `--json` | `niceeval docker profile list`, `niceeval docker profile doctor`, `niceeval docker cache inventory`, `niceeval docker cache gc` | boolean | Write this command's machine document or event stream. | | `--domain` | `niceeval docker cache inventory`, `niceeval docker cache gc` | string | Select one Docker cache domain managed by NiceEval. | | `--apply` | `niceeval docker cache gc` | string | Apply a previously issued Docker cache reclamation plan. | | `--help` | `niceeval docker` | boolean | Print help for this command. | | `--all` | `niceeval sandbox stop` | boolean | Destroy all retained Sandboxes. | | `--window` | `niceeval sandbox diff` | string | Select one recorded change window. | | `--path` | `niceeval sandbox diff` | string | Select one path in the Sandbox diff. | | `--leave-running` | `niceeval sandbox enter` | boolean | Keep the Sandbox after leaving the shell. | | `--record` | `niceeval sandbox list`, `niceeval sandbox enter`, `niceeval sandbox history`, `niceeval sandbox diff`, `niceeval sandbox stop`, `niceeval sandbox prune` | string | Use the given NiceEval Record root. | | `--orphans` | `niceeval sandbox list` | boolean | Check for unregistered Sandbox instances left by terminated runs. | | `--force` | `niceeval sandbox prune` | boolean | Also clean up unverified orphan candidates. | | `--development` | `niceeval sandbox` | boolean | Diagnose the isolated Incus development domain instead of reference. | | `--help` | `niceeval sandbox` | boolean | Print help for this command. | | `--tag` | `niceeval list` | string | Select only Evals with this tag. | | `--help` | `niceeval list` | boolean | Print help for this command. | # Define What You Evaluate: Adapter Reference Source: https://niceeval.com/docs/reference/define-agent Context, assertion methods, and resource lifecycle for custom Adapters and Agent integrations. An Adapter connects the object you evaluate and provides its operations and evidence. Agents, games, and ordinary applications all use NiceEval's Experiments, assertions, and result queries. A custom object provides native methods through `defineAdapter` and can bind domain checks in `assertions`; several implementations share an interface and assertions through `defineAdapterContract`. For the complete definition and how to run it, see [Evaluate a custom application](/docs/tutorials/custom-application). `defineAgent` and `defineSandboxAgent` below are dedicated to conversational integration, using `send` to return the standard event stream. ## `defineAgent` Use this for direct agent integration: ```ts theme={null} import { completeEvidenceCoverage, defineAgent } from "niceeval/adapter"; ``` An Agent produced by `defineAgent` always has `Agent.kind` set to `"direct"` (an internal discriminator you do not declare). `t` has no capability bits that must be declared to unlock them; filesystem assertions such as `t.sandbox` exist only on Agents built with `defineSandboxAgent` (`kind: "sandbox"`). Everything else is determined by the events `send` actually returns and how `ctx.session` is used; see the [Capabilities reference](/docs/reference/capabilities). For the complete field set, see "Agent and Adapter Context fields" below. ### Direct agent example ```ts theme={null} export default defineAgent({ name: "echo", evidenceCoverage: completeEvidenceCoverage, async send(input) { return { status: "completed", events: [{ type: "message", role: "assistant", text: input.text }], }; }, }); ``` ## `input` (`TurnInput`) The text passed to the current `t.send(...)`. Files attached to this turn (images and so on). An adapter that does not support multimodal input can ignore them. Present only on response turns (`t.respond` / `t.respondAll`): structured answers per request, matched by `requestId`. ## `defineSandboxAgent` Use this for coding agent CLIs. The resulting `Agent.kind` is always `"sandbox"`; filesystem assertions such as `t.sandbox` and `t.sandbox.fileChanged()` are unlocked only on this kind of agent. The complete field set is also in "Agent and Adapter Context fields" below, under `SandboxAgentDef`. The complete methods of `ctx.sandbox` (the `Sandbox` interface) are in "Sandbox interface" further below. ```ts theme={null} import { completeEvidenceCoverage, defineSandboxAgent } from "niceeval/adapter"; import { command } from "niceeval/sandbox"; export default defineSandboxAgent({ name: "my-cli-agent", evidenceCoverage: completeEvidenceCoverage, ensure: { identity: { agent: "my-cli-agent", version: "1.0.0", revision: "1" }, probe: command("my-agent", ["--version"]), }, async send(input, ctx) { await ctx.sandbox.runCommand("my-agent", ["run", input.text]); return { status: "completed", events: [] }; }, }); ``` ## Register it ```ts theme={null} import { defineExperiment } from "niceeval"; import echo from "./agents/echo"; export default defineExperiment({ agent: echo, attempts: 1, }); ``` ## Agent and Adapter Context fields The construction parameters of `defineAgent` (`DirectAgentDef`) and `defineSandboxAgent` (`SandboxAgentDef`), plus the `ctx` (`AgentContext`) that both receive in `send(input, ctx)`: A custom Adapter's `create(ctx)` uses `AdapterCreateContext`, and its cleanup callback uses `AdapterCleanupContext`. ### `AdapterAssertionsFactoryContext` #### `app` ```ts theme={null} readonly app: MatchContext; ``` #### `check` ```ts theme={null} readonly check: AssertionCheck<"polymorphic", Context>; ``` ### `AdapterCleanupContext` #### `timeoutMs` ```ts theme={null} readonly timeoutMs: number; ``` A fixed millisecond budget for the whole cleanup window; it is not renewed per callback. #### `deadlineAt` ```ts theme={null} readonly deadlineAt: number; ``` The Unix millisecond deadline fixed when the cleanup window actually starts. #### `signal` ```ts theme={null} readonly signal: AbortSignal; ``` Independent of the execution signal; aborts when the whole Adapter cleanup window reaches its deadline. ### `AdapterCreateContext` #### `models` ```ts theme={null} readonly models: ResolvedModelSlots; ``` Frozen application model selections for this Attempt. #### `recordUsage` ```ts theme={null} recordUsage(input: import("./adapter-usage.ts").AdapterUsageInput): void; ``` #### `sealUsage` ```ts theme={null} sealUsage(input: import("./adapter-usage.ts").AdapterUsageSeal): void; ``` Close application usage admission and declare whether the recorded collection is complete. #### `attach` ```ts theme={null} attach(input: import("./adapter-attachments.ts").AdapterAttachmentInput): Promise; ``` #### `recordTrace` ```ts theme={null} recordTrace( input: import("./adapter-execution-trace.ts").ExecutionTraceInput, ): Promise; ``` #### `evalId` ```ts theme={null} readonly evalId: string; ``` The public ID of the current Eval. #### `experimentId` ```ts theme={null} readonly experimentId: string; ``` The public ID of the current Experiment. #### `attempt` ```ts theme={null} readonly attempt: number; ``` The zero-based index of the current Attempt. #### `signal` ```ts theme={null} readonly signal: AttemptSignal; ``` The Attempt execution signal; its reason distinguishes timeout from cancelled, and it is not used for the cleanup window. #### `model` ```ts theme={null} readonly model?: string; ``` The model selected by the Experiment. #### `reasoningEffort` ```ts theme={null} readonly reasoningEffort?: string; ``` The reasoning effort selected by the Experiment. #### `flags` ```ts theme={null} readonly flags: Flags; ``` The read-only flags the Experiment passes to the Adapter. #### `progress` ```ts theme={null} progress(update: ProgressUpdate): void; ``` Updates the human-readable progress of the current Attempt. #### `diagnostic` ```ts theme={null} diagnostic(input: DiagnosticInput): void; ``` Appends a structured diagnostic to the current Attempt. #### `log` ```ts theme={null} log(message: string): void; ``` Appends a log line to the current Attempt. #### `onCleanup` ```ts theme={null} onCleanup(cleanup: (context: AdapterCleanupContext) => void | Promise): void; ``` Registers release of an Attempt-local resource. Callbacks run in global LIFO order; one failure does not skip the rest. Zero-argument callbacks can still be passed directly. The context passed in is frozen, and callbacks in the same cleanup window share one signal. ### `DirectAgentDef` #### `name` ```ts theme={null} name: string; ``` The agent's display name/identifier, copied verbatim into `Agent.name`. It is not a registry lookup key; it is used only for display, result attribution, and the dedupe fingerprint. #### `evidenceCoverage` ```ts theme={null} evidenceCoverage: EvidenceCoverage; ``` This Adapter's normal evidence coverage declaration; use `completeEvidenceCoverage` for complete collection. #### `setup` ```ts theme={null} setup?: DirectAgentSetup; ``` Once per attempt. A Direct Agent does not receive a Sandbox; commonly used for one-time preparation such as opening connections and authentication. #### `tracing` ```ts theme={null} tracing?: Omit; ``` OTLP export configuration: how the system under test sends traces to the endpoint (env-based injection). #### `spanMapper` ```ts theme={null} spanMapper?: SpanMapper; ``` A thin mapper from native spans → canonical; omitted uses the generic heuristic. Affects only the waterfall. #### `send` ```ts theme={null} send(input: TurnInput, ctx: AgentSendContext): Promise; ``` Once per turn: sends one turn's prompt directly to a function, SDK, or service endpoint, and parses the response into events. The author callback keeps the Promise ABI. #### `classifySendFailure` ```ts theme={null} classifySendFailure?: SendFailureClassifier; ``` Optional send execution failure classifier: see `Agent.classifySendFailure`. #### `teardown` ```ts theme={null} teardown?: DirectAgentTeardown; ``` Cleanup before the run ends, executed if and only if this attempt reached the `setup` point (a `setup` throw does not exempt it), run once in finally. ### `SandboxAgentDef` #### `name` ```ts theme={null} name: string; ``` The agent's display name/identifier, copied verbatim into `Agent.name`. It is not a registry lookup key; it is used only for display, result attribution, and the dedupe fingerprint. #### `evidenceCoverage` ```ts theme={null} evidenceCoverage: EvidenceCoverage; ``` This Adapter's normal evidence coverage declaration; use `completeEvidenceCoverage` for complete collection. #### `sandbox` ```ts theme={null} sandbox?: SandboxLayer<"command-only">; ``` The Adapter's own Sandbox actions; an Agent cannot choose or contribute a template. #### `ensure` ```ts theme={null} ensure: AgentEnsure | readonly AgentEnsure[]; ``` A single entry or an array is normalized into Agent layers in declaration order. #### `installers` ```ts theme={null} installers?: readonly AgentInstaller[]; ``` Paired installation layers; omitted means this adapter provides only the probe protocol, and on a miss the Runner names the exact missing identity at `agent.ensure`. The factory normalizes an omission into an empty array. #### `setup` ```ts theme={null} setup?: AgentSetup; ``` Once per Attempt (not once per send): writes config.toml / authentication configuration (model/base/auth and other runtime configuration that does not change within this Attempt). Probing, installing, and rechecking the CLI belong to `agent.ensure`. The runner calls it once after the Sandbox is ready (after layer prepare/ensure/baseline) and before the first send; it returns no value. #### `tracing` ```ts theme={null} tracing?: AgentTracing; ``` OTLP export configuration: how to make the CLI in the Sandbox send traces to the endpoint (env / config file), split out of setup. #### `spanMapper` ```ts theme={null} spanMapper?: SpanMapper; ``` A thin mapper from native spans → canonical; omitted uses the generic heuristic. Affects only the waterfall. #### `send` ```ts theme={null} send(input: TurnInput, ctx: SandboxAgentSendContext): Promise; ``` Once per turn: runs the prompt (fresh / resume) and parses it into events. The author callback keeps the Promise ABI. #### `classifySendFailure` ```ts theme={null} classifySendFailure?: SendFailureClassifier; ``` Optional send execution failure classifier: see `Agent.classifySendFailure`. #### `teardown` ```ts theme={null} teardown?: AgentTeardown; ``` Cleanup before the Sandbox is destroyed, executed if and only if this attempt reached the `setup` point (a `setup` throw does not exempt it), run once in finally. ### `AgentContext` #### `signal` ```ts theme={null} readonly signal: AbortSignal; ``` Soft cancellation signal: combines the attempt timeout, run-level interruption (user Ctrl+C), and the Eval's own interruption request (see src/runner/attempt.ts). An adapter can optionally check it (or pass it straight to `fetch`) to exit gracefully early, but it is not the only hard boundary: even if the adapter ignores it entirely, the runner uses `Effect.timeoutTo` as a backstop to force teardown (stopping the Sandbox container). #### `model` ```ts theme={null} readonly model?: string; ``` The model name used by this attempt, passed through from the Experiment's `model` field. Sandbox agents usually write it into configuration in setup; direct agents usually use it in send to select the model. #### `reasoningEffort` ```ts theme={null} readonly reasoningEffort?: string; ``` The model's reasoning effort; owned the same way as model: the Experiment decides it, and when omitted the agent's native default is not overridden. #### `flags` ```ts theme={null} readonly flags: ExperimentFlags; ``` The Experiment's `flags` field passed through verbatim; its content and structure are entirely defined by the Experiment author (such as `{ webResearch: true }`, `{ systemPrompt: "..." }`). The adapter reads its fields by its own convention; the framework itself neither interprets nor validates the content. The name deliberately avoids the CLI-parsed `flag` (run-level --timeout/--budget and so on); the two are unrelated concepts. #### `experimentId` ```ts theme={null} readonly experimentId?: string; ``` The Experiment id derived from the path (the same source as result attribution `runWho` / `AgentRun.experimentId`); undefined when not running through an Experiment (such as outside the CLI, constructing `AgentRun` directly). Typical uses: a SandboxLayer command isolating cross-attempt infrastructure content per Experiment, or an adapter switching authentication / routing per Experiment. It is a different dimension from `flags` (the concrete values of Experiment conditions): this is only a stable identifier of "which Experiment is running" and carries no condition content. #### `evalId` ```ts theme={null} readonly evalId?: string; ``` The eval ID of the current Attempt. The NiceEval Runner always fills it from the Eval identity after discovery; third parties constructing AgentContext directly can omit it. An Adapter can use it to locate read-only host assets in the same directory as the task, but must not use it to bypass the Sandbox's hidden-criteria isolation. #### `evalGroup` ```ts theme={null} readonly evalGroup?: { readonly id: string; readonly definitionHash: string; }; ``` The eval group of the current Attempt; omitted for ungrouped Evals. #### `attempt` ```ts theme={null} readonly attempt?: AttemptRef; ``` The current Attempt reference filled in by the Runner; third parties constructing the context directly can omit it. #### `session` ```ts theme={null} readonly session: AgentSession; ``` #### `telemetry` ```ts theme={null} readonly telemetry?: Telemetry; ``` Present only when OTel integration is configured (the agent's `tracing` block / the config's telemetry exists): this run's OTLP traces receiver information (endpoint + env for env-based export). How to hand it to the CLI is declared by the agent's `tracing` block: env-based spreads ctx.telemetry.env into send; file-based writes configuration in tracing.configure. A remote HTTP integration's send only needs to spread headers into the request headers (a new traceparent per turn); the endpoint is fixed by startup configuration (defineConfig(\{ telemetry: \{ port } })) and is not passed from here. #### `progress` ```ts theme={null} progress(update: ProgressUpdate): void; ``` Scoped feedback: reports what is happening right now (turn / tool / install progress). Short-lived state: the Human profile updates the active line, `agent`/`ci` do not print each one, and it does not enter the final result; do not call it for every token/delta. The runner attributes it by the lifecycle phase the callback is currently in (agent.setup / agent.run / agent.teardown); the caller cannot impersonate another phase (see docs/feature/experiments/library.md). #### `diagnostic` ```ts theme={null} diagnostic(input: DiagnosticInput): void; ``` Scoped feedback: reports problems that should remain after the run ends (protocol downgrade, incomplete data, cleanup issues). A permanent event that lands in the attempt's diagnostics and each profile's permanent output; deduplicated by `dedupeKey`. Even with level "error" it does not change Turn.status / verdict; throw when you cannot continue. #### `log` ```ts theme={null} log(msg: string): void; ``` An explicit timeout breadcrumb. It also updates the Human active line, and the most recent entries are merged into the result's error information on a timeout failure; do not use it to carry user messages, tool input, or other text meant only for short-lived display. `ctx.session` (`AgentSession`) is the state slot for one session line: every `send` on the same session line gets the same `ctx.session`, and a new session line (the Eval's first turn / after `t.newSession()`) gets a fresh one. Accessors: * `id?: string` / `capture(id): void` — for session continuation when the server keeps history: `id` is the session id this line has recorded (`undefined` on a new line), and `capture` records the returned id (it takes effect only if none has been recorded yet). * `createSessionSlot(name)` — creates a typed slot at Adapter module scope. Slots are isolated by symbol identity. * `get(slot)` / `set(slot, value)` — store and read client-side history or Adapter-private state. * `take(slot)` — reads and deletes the HITL pause state, consumed once. For the complete contract, see [Adapter concepts](/docs/explanation/adapter#context-agentcontext). ## Sandbox interface `ctx.sandbox` (`Sandbox`) of a Sandbox agent is the handle to the current isolated environment. `Sandbox` extends `SandboxOperations` and `SandboxTransferOperations`; all members are listed below by declaring interface. `CommandOptions` holds the options for `runCommand` / `runShell`: ### `SandboxOperations` #### `workdir` ```ts theme={null} readonly workdir: string; ``` The absolute path of the project/workspace root inside the Sandbox (the default cwd for agent commands, and where the git baseline is committed). Relative paths in every method resolve against it, and omitting `cwd`/`targetDir` also lands here. #### `runCommand` ```ts theme={null} runCommand(cmd: string, args?: readonly string[], opts?: CommandOptions): Promise; ``` Runs a single command; `args` are passed as separate argv entries and are not interpreted by a shell (no `&&`, pipes, or glob expansion). Prefer it when you only want to run one executable, arguments come from external input, or you are worried about injection. #### `runShell` ```ts theme={null} runShell(script: string, opts?: CommandOptions): Promise; ``` Runs a whole script interpreted by a shell (bash), supporting `&&`, pipes, `$()`, redirection, and so on. Use it when you need to chain several commands or branch on conditions. #### `runCommandOrThrow` ```ts theme={null} runCommandOrThrow( cmd: string, args?: readonly string[], opts?: CommandOptions, ): Promise; ``` Runs a single command like `runCommand`, but throws `SandboxCommandExitError` on a non-zero exit. The error message includes a bounded, cleaned, and redacted stderr tail; it falls back to stdout when stderr is empty. The full output is kept in the exception's `result` field. A successful result's `exitCode` is fixed to `0` in its type. #### `runShellOrThrow` ```ts theme={null} runShellOrThrow(script: string, opts?: CommandOptions): Promise; ``` Runs a shell script like `runShell`, but throws `SandboxCommandExitError` on a non-zero exit. The error summary, full output, and successful result semantics are the same as `runCommandOrThrow`. #### `readText` ```ts theme={null} readText(path: string): Promise; ``` Reads the text content (UTF-8) of a file inside the Sandbox. Throws when the file does not exist. #### `writeText` ```ts theme={null} writeText(path: string, content: string): Promise; ``` Writes a UTF-8 text file inside the Sandbox; missing parent directories are created automatically. #### `readBytes` ```ts theme={null} readBytes(path: string): Promise; ``` Reads a file's exact bytes inside the Sandbox; the public contract is not bound to Node Buffer. #### `writeBytes` ```ts theme={null} writeBytes(path: string, content: Uint8Array): Promise; ``` Writes a file's exact bytes inside the Sandbox; missing parent directories are created automatically. #### `pathExists` ```ts theme={null} pathExists(path: string): Promise; ``` Checks whether a file or directory path exists inside the Sandbox. ### `SandboxTransferOperations` #### `upload` ```ts theme={null} upload(content: SandboxContent, targetPath: string): Promise; ``` Transfers registered immutable content without exposing its host path. #### `uploadFile` ```ts theme={null} uploadFile(source: string | URL, targetPath: string): Promise; ``` #### `uploadDirectory` ```ts theme={null} uploadDirectory( sourceDir: string | URL, targetDir?: string, options?: { readonly ignore?: readonly string[] }, ): Promise; ``` #### `downloadFile` ```ts theme={null} downloadFile(sourcePath: string, target: string | URL): Promise; ``` #### `downloadDirectory` ```ts theme={null} downloadDirectory( sourceDir: string, targetDir: string | URL, options?: { readonly ignore?: readonly string[] }, ): Promise; ``` ### `Sandbox` #### `stop` ```ts theme={null} stop(): Promise; ``` Destroys the compute resources the Sandbox occupies (container/microVM). The Sandbox cannot be used after the call; whether repeated calls are safe varies by provider, so do not rely on it. #### `sandboxId` ```ts theme={null} readonly sandboxId: string; ``` This Sandbox's stable identifier (each provider's native ID, such as a Docker container ID prefix); used to correlate session state of the same Sandbox across calls, and for log display. #### `otlpHost` ```ts theme={null} readonly otlpHost: string | null; ``` The placement capability of the OTLP receiver. * `string`: the host receiver is reachable from inside the Sandbox through this hostname. * `null`: the provider does not promise a connection back to the host; the runner tries to start an attempt-scope receiver inside the Sandbox. This does not guarantee tracing succeeds; when the image lacks the runtime the receiver needs, only a supplemental diagnostic is recorded. `defineConfig({ telemetry: { host } })` can override it explicitly when the author already provides a controlled tunnel. #### `appendLog` ```ts theme={null} appendLog?(line: string): Promise; ``` Optional: writes a line into the container's "main log" (which PID1 tails), so `docker logs` / the Logs tab of the Docker UI shows the agent's turn-by-turn activity live. Implemented by the docker provider; others can omit it. ### `CommandOptions` #### `sensitiveValues` ```ts theme={null} readonly sensitiveValues?: readonly string[]; ``` Sensitive plaintext this command is known to handle (such as API keys, tokens, HTTP header values). The Runner still hands the original values to the provider for execution, but replaces these values exactly before any timing / commands / execution / error evidence is persisted; this array itself is never persisted and does not enter the fingerprint. Empty strings are ignored. This is explicit provenance, not a secret scanner: unregistered free text cannot be reliably identified; if the caller encodes or splits a value first, also register the encoded form that will actually appear in the command/output. #### `env` ```ts theme={null} readonly env?: Readonly>; ``` Adds/overrides environment variables for this command (layered on the Sandbox default environment without clearing defaults). `PATH` is a Sandbox managed variable; each provider keeps the `PATH` it computed and does not guarantee it can be overridden here; to extend PATH, use the Sandbox factory's `pathPrepend` (see docs/feature/sandbox/library.md "PATH: managed variable and pathPrepend"). #### `cwd` ```ts theme={null} readonly cwd?: string; ``` This command's working directory; omitted lands on `Sandbox.workdir`. Relative paths resolve against the workdir, and absolute paths are used as is. #### `stream` ```ts theme={null} readonly stream?: boolean; ``` Also sends this command's output into the Sandbox's "native log stream" (so `docker logs` / the Logs tab of the Docker UI shows it live). Turn it on for agent commands (codex exec / bub run / claude) to see the agent's raw output in the container log. Each provider implements it (docker: tee into the file PID1 tails; unsupported providers ignore it); how the log surfaces is the provider's business, and the adapter only declares intent. #### `onStdout` ```ts theme={null} readonly onStdout?: (chunk: string) => void | Promise; ``` Called once for each stdout chunk of the command. The callback is only for short-lived feedback while running; the full stdout still appears verbatim in the returned `CommandResult`. When the provider does not support real streaming, it calls it at least once with the full stdout after the command ends; it must not drop it silently. #### `onStderr` ```ts theme={null} readonly onStderr?: (chunk: string) => void | Promise; ``` The stderr counterpart of `onStdout`; the full stderr is still kept in `CommandResult`. #### `user` ```ts theme={null} readonly user?: string; ``` Overrides the execution identity of this command; omitted = the Sandbox default identity (the identity the environment declares itself: the Docker image `USER`, the Compose service `user:`, the E2B template default user; see docs/feature/sandbox/library.md "Execution identity"). The semantics are consistent across providers, and each provider maps them to its own native mechanism (docker: `exec --user`; E2B: `{ user }`; Vercel: accepts only `"root"`, mapped to `{ sudo: true }`, other values error). Providers that run as root throughout treat it as a no-op; providers that cannot switch identity at all may not support it (and throw), but **the semantics of omission and explicit values stay consistent** and do not vary by provider. #### `timeoutMs` ```ts theme={null} readonly timeoutMs?: number; ``` This command's own limit (milliseconds). **Omitting it is the norm**: when omitted, the limit = the remaining attempt deadline (see docs/feature/sandbox/architecture.md "Deadline ownership"); the provider layer has no independent default. Passing a shorter value explicitly is an intentional declaration and takes effect as usual; on hitting it, the attribution is recorded as "explicit command timeout". #### `signal` ```ts theme={null} readonly signal?: AbortSignal; ``` Cancels this managed command tree. The provider must confirm the command tree has terminated before the Promise settles; when it cannot terminate precisely, it should retire the whole Sandbox rather than only closing the transport and leaving processes in the background. ## ctx and t `ctx` is the run context the adapter side sees. `t` is the test context the Eval author sees. Both use the same run data, but have different responsibilities. # defineConfig: Project Defaults Source: https://niceeval.com/docs/reference/define-config defineConfig reference: Judge Runtime, Reporters, concurrency, timeout, and Sandbox defaults. `defineConfig` is the default export from `niceeval.config.ts` at the project root and holds only project-level defaults. Agent, model, Flags, Attempts, and Experiment budgets belong in `defineExperiment` files under `experiments/`. ```ts theme={null} import { defineConfig } from "niceeval"; import { OpenAIProvider } from "niceeval/judge"; export default defineConfig({ judgeRuntime: OpenAIProvider({ model: "gpt-5.4-mini", maxOutputTokens: 512 }), maxConcurrency: 4, timeoutMs: 300_000, }); ``` ## Config fields #### `name` ```ts theme={null} name?: LocalizedText; ``` The project name, shown in the hero (`

`) at the top of `niceeval view`; it falls back to a generic title when omitted. It can be a string, or text in multiple locales (such as `{ en: "...", "zh-CN": "..." }`) that switches with the view language. #### `workspace` ```ts theme={null} workspace?: string; ``` The workspace root uploaded into a Sandbox; it defaults to the project root. An Eval's Sandbox view starts from here. #### `judgeRuntime` ```ts theme={null} judgeRuntime?: JudgeProvider; ``` The project-level default Judge Provider; an Eval or Experiment can replace the model or the whole Provider. #### `reporters` ```ts theme={null} reporters?: Reporter[]; ``` The project-level default Reporter list (for example, persisting or uploading results); `EvalDef.reporters` merges with it. #### `maxConcurrency` ```ts theme={null} maxConcurrency?: number; ``` The project-level default concurrency limit; the CLI flag or Experiment setting with the same name has higher priority (there is no environment-variable layer). #### `maxBuildConcurrency` ```ts theme={null} maxBuildConcurrency?: number; ``` Run-level concurrency for preparing Sandbox images; independent of Attempt concurrency, with a safe default of 2 when omitted. #### `maxSetupPrefixConcurrency` ```ts theme={null} maxSetupPrefixConcurrency?: number; ``` Run-level concurrency for preparing the PreparedArtifact prefix DAG; independent of build and Attempt concurrency. #### `sandboxCache` ```ts theme={null} sandboxCache?: SandboxCacheConfig; ``` The project-level SetupPrefix cache default; an Experiment or an explicit CLI flag can override it. #### `timeoutMs` ```ts theme={null} timeoutMs?: number; ``` The project-level default timeout for one Attempt (milliseconds); the CLI flag, Experiment, or `EvalDef` setting with the same name has higher priority. #### `telemetry` ```ts theme={null} telemetry?: { host?: string; port?: number }; ``` OTLP receiver configuration, the only entry point inside a niceeval project (it does not read `NICEEVAL_OTLP_*` environment variables). `port` pins the receiving port (fixed-port mode: a long-running service points `OTEL_EXPORTER_OTLP_ENDPOINT` once at `http://localhost:/v1/traces`, and never changes it no matter how many Eval runs follow). When omitted, each run dynamically assigns a temporary port (handed to the Adapter through `ctx.telemetry`). The tradeoff: with a fixed port, only one niceeval process can run on the machine at a time, and an error occurs if another process holds that port; pick a free port and write it back here. `host` is the receiver hostname reported to the Adapter (not the listening address, which is always `0.0.0.0`); it defaults to `"127.0.0.1"`. Override it here only when you already provide a controlled tunnel or reachable route; a Docker Sandbox puts the receiver inside the Sandbox by default and does not rely on an implicit host gateway. #### `pricing` ```ts theme={null} pricing?: globalThis.Record; ``` User overrides or additions on top of the built-in price table (`o11y/prices.json`), looked up by model (see Observability · Usage and Cost). A key can be an exact model name or a `provider/*` wildcard (to override self-hosted or gateway discounts in bulk per Provider); exact keys take priority over wildcards. The Runner uses it to compute the budget estimate for `maxCost`; the Adapter physical-call collector seals a matching explicit fixed profile as a call-bound estimate proof only when reported cost is absent. Inspection reads only sealed proofs; it does not take mutable config as an operation input, and does not backfill historical calls by endpoint. > Cost boundary: this `pricing` only provides the Runner's Config/runtime price-table input. Whether or not `Usage.costUSD` already exists, the Runner independently produces `estimatedCostUSD` from the `model`, the reported token usage, and this table. The Experiment budget and `t.maxCost()` use that estimate; `Usage.costUSD` does not drive it. Reading results uses fixed Inspection operations, which do not turn runtime price configuration into another input. [NiceEval](https://niceeval.com/) keeps environment preparation in ordinary code: files an Eval needs go in `test(t)`, and an Agent's own preparation goes in the Adapter's setup. `Config` deliberately has no `sandbox` field and does not auto-detect a Provider. Declare a template-bearing `SandboxLayer` on the Eval or Experiment so link planning settles its owner and pairing result before creating any resources. # defineEval: Declare, Configure, and Run NiceEval Evals Source: https://niceeval.com/docs/reference/define-eval defineEval reference: options, the test context t, the Turn return value, Sandbox helpers, and array and keyed-record test set exports. `defineEval` is the main entry point for writing Evals. Call it once per Eval file, pass a description and `test(t)`, and default-export the result. For how to organize business criteria, evidence, and points, see [NiceEval Taste](/docs/tutorials/niceeval-taste). ```ts theme={null} import { defineEval } from "niceeval"; export default defineEval({ description: "Brooklyn weather query", async test(t) { const turn = await t.send("What's the weather like in Brooklyn today?"); turn.succeeded().label("Turn completed"); }, }); ``` Do not provide `id` or `name`. NiceEval generates the Eval ID from the file path. ## `defineEval` options #### `description` ```ts theme={null} description?: string; ``` A one-sentence description shown in `niceeval list` and the view; purely informational, it does not affect scheduling or scoring. #### `tags` ```ts theme={null} tags?: string[]; ``` Tags for CLI `--tag` filtering and view categorization; a selection dimension independent of id-prefix filtering. #### `sandbox` ```ts theme={null} sandbox?: SandboxLayer; ``` The Sandbox declaration layer this Eval contributes. Omitting it is equivalent to an empty command-only layer and provides no implicit template. In each actual Eval × Experiment pair, exactly one side must provide a template-bearing layer. #### `plugins` ```ts theme={null} plugins?: readonly PluginInstance<"eval">[]; ``` Explicit, immutable Eval Plugin occurrences; there is no directory inheritance. #### `judge` ```ts theme={null} judge?: JudgeSelection; ``` A model string replaces only the model of the project's Judge Provider; a Provider instance replaces it entirely. The Experiment setting takes priority. #### `reporters` ```ts theme={null} reporters?: Reporter[]; ``` Overrides / appends to the project-level Config.reporters, only for this Eval. #### `timeoutMs` ```ts theme={null} timeoutMs?: number; ``` Overrides the project-level / CLI per-attempt timeout (milliseconds), only for this Eval. #### `metadata` ```ts theme={null} metadata?: globalThis.Record; ``` Arbitrary extra metadata, saved as Attempt Provenance; it does not take part in scheduling or scoring and is for custom reporters to consume. #### `diff` ```ts theme={null} diff?: { include?: string[]; ignore?: string[] }; ``` Adjusts the attribution exclusion list for the agent diff (Sandbox only; see docs/feature/eval/README.md): both arrays are gitignore-style globs (relative to the workdir). By default .git/node\_modules/build output/package-manager caches are excluded; `ignore` adds exclusions on top of the default list; `include` has the highest priority and explicitly adds matching paths back. The composition rule is fixed as "default ∪ ignore, then punched through by include", and the list is frozen at the ledger anchor. #### `test` ```ts theme={null} test(t: TestContext): EvalTestReturn; ``` ## Test context: `t` `t` (`TestContext`) is the context an Eval author receives in `test(t)`. Each call to a check method registers one assertion. * `t.check(subject, match)` checks a value you pass explicitly against a match condition. * `t.check(contextualMatch)` takes no value; NiceEval hands the current read-only ctx to the match condition to read. * `t.judge(material, match)` gives material to a Judge for scoring, through the same registration path as `t.check`. `judge` on the root, a Session, and a Turn all require material passed explicitly. NiceEval ships four ready-made judges, which also build a match condition and hand it to `check`. `closeQA(selector, question)` first selects a set of material with the selector, then has the Judge answer an acceptance question. Agent scenarios also have `closeQA(question)`, which reads the current scope's full history by default; you can also pass an EventMatch or ToolMatch to look at only part of the facts. How an assertion affects the result by default depends on the Eval type: * In a pass/fail Eval, Boolean assertions decide the Attempt's Verdict by default; in a scored Eval, Boolean assertions only record by default and do not affect the result. * Scoring assertions (measurements) do not fail the Eval by default. In both Eval types, after adding `.gate(minimum)`, a score below the minimum counts as a failure. In a scored Eval, the same assertion can have both `.gate()` and `.score()`. * `.orStop()` stops later steps when the assertion fails. A Boolean assertion uses its own result; a measurement writes `.orStop(minimum)`, or a parameterless `.orStop()` after `.gate(minimum)` to reuse the gate's minimum. Compute ordinary formulas directly in TypeScript, then register them with `t.score(ratio, { weight: 50 }).label("Completion time")`. `ratio` is a number between 0 and 1, and `weight` is a non-negative finite number. The result keeps the ratio, the corresponding points, and the actual score. When the numeric material it depends on is unavailable, the item's score is recorded as unknown. The `ScoreTestContext` of `defineScoreEval` also provides `t.score(n)`, which registers a score item directly; existing assertions count toward the score with `.score(n)`. In both places `n` must be a finite number not less than zero, and 0 is also an explicit score. For the complete rules of `calledTool` and `notCalledTool`, see [Scoped assertions](https://github.com/NiceEval/NiceEval/blob/main/docs/feature/assertions/library/scoped-assertions.md). All members are: #### `evaluationKind` ```ts theme={null} readonly evaluationKind: Kind; ``` #### `send` ```ts theme={null} send(input: string | { readonly text: string; readonly files?: readonly InputFile[] }): Promise>; ``` #### `sendFile` ```ts theme={null} sendFile(path: string, text?: string): Promise>; ``` #### `requireInputRequest` ```ts theme={null} requireInputRequest(filter?: InputRequestFilter): InputRequest; ``` #### `respond` ```ts theme={null} respond(...responses: readonly (string | InputAnswer)[]): Promise>; ``` #### `respondAll` ```ts theme={null} respondAll(optionId: string): Promise>; ``` #### `reply` ```ts theme={null} readonly reply: string; ``` #### `sessionId` ```ts theme={null} readonly sessionId: string | undefined; ``` #### `events` ```ts theme={null} readonly events: readonly StreamEvent[]; ``` #### `eventOccurrences` ```ts theme={null} readonly eventOccurrences: ManagedEventOccurrences<"attempt">; ``` #### `newSession` ```ts theme={null} newSession(): SessionHandle; ``` #### `signal` ```ts theme={null} readonly signal: AbortSignal; ``` #### `model` ```ts theme={null} readonly model?: string; ``` #### `reasoningEffort` ```ts theme={null} readonly reasoningEffort?: string; ``` #### `flags` ```ts theme={null} readonly flags: ExperimentFlags; ``` #### `progress` ```ts theme={null} progress(update: import("../types.ts").ProgressUpdate): void; ``` #### `diagnostic` ```ts theme={null} diagnostic(input: import("../types.ts").DiagnosticInput): void; ``` #### `log` ```ts theme={null} log(message: string): void; ``` #### `skip` ```ts theme={null} skip(reason: string): never; ``` #### `group` ```ts theme={null} group( title: string, body: () => Value | PromiseLike, ): Promise>; ``` #### `check` ```ts theme={null} check: import("../assertions/api.ts").AssertionCheck>; ``` #### `judge` ```ts theme={null} judge: JudgeFunction; ``` #### `toolCalls` ```ts theme={null} readonly toolCalls: ManagedToolCalls<"attempt">; ``` #### `sandbox` ```ts theme={null} readonly sandbox: Sandbox; ``` #### `o11y` ```ts theme={null} readonly o11y: import("../o11y/types.ts").O11ySummary; ``` #### `usage` ```ts theme={null} readonly usage: EvalUsage; ``` #### `succeeded` ```ts theme={null} succeeded(): BooleanAssertionHandle; ``` #### `usedNoTools` ```ts theme={null} usedNoTools(): BooleanAssertionHandle; ``` #### `maxToolCalls` ```ts theme={null} maxToolCalls(max: number): BooleanAssertionHandle; ``` #### `noFailedActions` ```ts theme={null} noFailedActions(): BooleanAssertionHandle; ``` #### `event` ```ts theme={null} event(match: EventMatch | EventOccurrenceMatch): BooleanAssertionHandle; ``` #### `notEvent` ```ts theme={null} notEvent(match: EventMatch): BooleanAssertionHandle; ``` #### `elapsedMs` ```ts theme={null} readonly elapsedMs: import("../assertions/match.ts").NumericMaterial; ``` #### `maxTokens` ```ts theme={null} maxTokens(max: number): BooleanAssertionHandle; ``` #### `maxCost` ```ts theme={null} maxCost(usd: number | string): BooleanAssertionHandle; ``` #### `closeQA` ```ts theme={null} closeQA(question: string, options?: JudgePresetOptions): MeasurementAssertionHandle; ``` #### `closeQA` ```ts theme={null} closeQA(selector: ToolMatch | EventMatch, question: string, options?: JudgePresetOptions): MeasurementAssertionHandle; ``` #### `closeQA` ```ts theme={null} closeQA(selector: MaterialMatch, question: string, options?: JudgePresetOptions): MeasurementAssertionHandle; ``` ## Judge scoring First declare a rubric with `defineJudge`, then pass it directly to `t.check` or `t.judge`: ```ts theme={null} const factualSummary = defineJudge({ name: "factual-summary", rubric: "Judge, against source, whether summary faithfully keeps the facts without inventing any.", }); const turn = await t.send("Summarize the requirements."); t.judge({ source: expected, summary: turn.message }, factualSummary) .gate(0.8) .label("Factually consistent"); ``` Judge material can be a size-bounded JSON value or an image produced by `judgeImage()`; a snapshot is saved at registration. Which model the Judge uses is decided in order: first the project config's `judgeRuntime`, then the Eval's `judge`, and finally the Experiment's `judgeRuntime`. When a later layer gives a complete Provider, it replaces the service and execution configuration entirely; when it gives only a model string, it replaces just the model of the selected Provider. An Eval's `judge` only decides which model or Provider this Eval uses; it does not restrict which match conditions can be used. Both pass/fail and scored Evals can call `.gate(minimum)`. Scored Evals can also call `.score(n)`, and the item keeps its score when the gate fails. ### Image material Import `judgeImage` from `niceeval/judge`; it copies the original image data immediately. The limits are: * Only PNG and JPEG formats are supported. * Each image is at most 4 MiB and 16,777,216 pixels. * Each assertion and each model request can reference images at most 4 times, totaling no more than 8 MiB. Referencing the same image again also counts. * Each Attempt saves at most 32 MiB of images. NiceEval checks only the file signature, header, and dimensions; it does not fully decode or transcode images. `OpenAIProvider`, `VercelProvider`, and `OpenRouterProvider` do not send images by default. Set `supportsImages: true` explicitly and choose a model that supports both images and tool calls. `TypesafeProvider` does not support images. When images are unsupported, the result is `judge-capability-unavailable`; images are never swapped for a text description before scoring. #### `judgeImage` ```ts theme={null} export function judgeImage(input: JudgeImageInput): JudgeImage { ... } ``` Capture PNG or JPEG bytes for an existing Judge call without sending a request. ### `JudgeImageInput` #### `body` ```ts theme={null} readonly body: Uint8Array; ``` Original encoded image bytes; copied synchronously, at most 4 MiB. #### `mediaType` ```ts theme={null} readonly mediaType: "image/png" | "image/jpeg"; ``` Must match the encoded image. Paths, URLs and SVG are not accepted. ### Ready-made judges and custom Matches `defineJudge` and the ready-made judges all return a `ScoreMatch`, which you can pass directly to `t.check`. `t.judge` is just shorthand for the same path, so registration, snapshot, budget, scoring, audit, and result storage each happen only once. | Judge | Input | Score meaning | | - | - | - | | `factuality()` | `{ input, output, expected }` | 1 when consistent, 0.5 when incomplete, 0 when contradictory | | `faithfulness()` | `{ input, output, context }` | The share of facts in the output supported by the context | | `instructionFollowing()` | `{ instructions, output }` | The share of explicit requirements met | | `pairwisePreference()` | `{ instructions, output, reference }` | 1 when the candidate is better, 0.5 for a tie, 0 when the reference is better | | `t.closeQA(selector, question)` | A MaterialMatch that reads material from ctx and judges item by item | 1 when acceptance is met, 0 when not, unavailable when evidence is insufficient | For example, using an `instructionFollowing()` instance directly: ```ts theme={null} t.check( { instructions: ["State the meeting place", "Do not invent a specific time"], output: post.content }, followsInstructions, ).gate(0.7).score(25); ``` `faithfulness` first extracts facts from the output, judges them one by one, and finally computes the ratio in code. When extraction is incomplete there is no valid score. TypeSafe does not support `extract`, so on `TypesafeProvider` the result is `unavailable`; it is never switched to estimating one overall score. `pairwisePreference` compares once in a fixed order and suits a comparison between one candidate and one reference. `closeQA` gives all selected material to the Judge at once in original order, and the Judge answers the acceptance question based only on that material. Agent scenarios can use `turn.closeQA(question)`, `session.closeQA(question)`, and `t.closeQA(question)`. Passing an EventMatch looks only at the matching events; passing a ToolMatch looks at the complete information of the matching tool calls. The state of the material affects the result: when the selected material is complete but empty, the score is 0 and the model is not called; when the material is incomplete, undecidable, or over the limit, the result is `unavailable`, and it is never truncated before scoring. When you need to combine several model steps, write your own scoring logic with `score(value, context)` in `defineScoreMatch`. `context.llm` provides `score`, `classify`, `extract`, and `batchClassify`, all returning an Effect. They share the call budget, timeout settings, and audit records with the ready-made judges. * Static configuration validation sends no network requests. No request is sent when the Provider, model, or key is missing either. * Chat-style services use forced-function requests, and TypeSafe uses the `/systemone` endpoint; both validate responses strictly. * Network transport errors or timeouts make the result unavailable; HTTP 400 or protocol incompatibility makes it errored. For the complete rules, see [Custom Matches](https://github.com/NiceEval/NiceEval/blob/main/docs/feature/assertions/library/custom-assertions.md). ## The `Turn` return type `t.send(...)` returns a `TurnHandle`. It contains common fields derived from the event stream, plus a full set of assertions scoped to this turn only. `calledTool` and `notCalledTool` are also assertions on the Turn; for their signatures and matching rules, see [Scoped assertions](https://github.com/NiceEval/NiceEval/blob/main/docs/feature/assertions/library/scoped-assertions.md). #### `input` ```ts theme={null} readonly input: string; ``` #### `events` ```ts theme={null} readonly events: readonly StreamEvent[]; ``` #### `toolCalls` ```ts theme={null} readonly toolCalls: ManagedToolCalls<"turn">; ``` #### `eventOccurrences` ```ts theme={null} readonly eventOccurrences: ManagedEventOccurrences<"turn">; ``` #### `status` ```ts theme={null} readonly status: "completed" | "failed" | "waiting"; ``` #### `message` ```ts theme={null} readonly message: string; ``` #### `data` ```ts theme={null} readonly data?: JsonValue; ``` #### `usage` ```ts theme={null} readonly usage: EvalUsage; ``` #### `check` ```ts theme={null} check: import("../assertions/api.ts").AssertionCheck>; ``` #### `judge` ```ts theme={null} judge: JudgeFunction; ``` #### `succeeded` ```ts theme={null} succeeded(): BooleanAssertionHandle; ``` #### `toolOrder` ```ts theme={null} toolOrder(matches: readonly [ToolMatch, ToolMatch, ...ToolMatch[]]): BooleanAssertionHandle; ``` #### `usedNoTools` ```ts theme={null} usedNoTools(): BooleanAssertionHandle; ``` #### `maxToolCalls` ```ts theme={null} maxToolCalls(max: number): BooleanAssertionHandle; ``` #### `noFailedActions` ```ts theme={null} noFailedActions(): BooleanAssertionHandle; ``` #### `event` ```ts theme={null} event(match: EventMatch | EventOccurrenceMatch): BooleanAssertionHandle; ``` #### `notEvent` ```ts theme={null} notEvent(match: EventMatch): BooleanAssertionHandle; ``` #### `eventOrder` ```ts theme={null} eventOrder(matches: readonly [EventMatch, EventMatch, ...EventMatch[]]): BooleanAssertionHandle; ``` #### `elapsedMs` ```ts theme={null} readonly elapsedMs: import("../assertions/match.ts").NumericMaterial; ``` #### `maxTokens` ```ts theme={null} maxTokens(max: number): BooleanAssertionHandle; ``` #### `maxCost` ```ts theme={null} maxCost(usd: number | string): BooleanAssertionHandle; ``` #### `closeQA` ```ts theme={null} closeQA(question: string, options?: JudgePresetOptions): MeasurementAssertionHandle; ``` #### `closeQA` ```ts theme={null} closeQA(selector: ToolMatch | EventMatch, question: string, options?: JudgePresetOptions): MeasurementAssertionHandle; ``` #### `closeQA` ```ts theme={null} closeQA(selector: MaterialMatch, question: string, options?: JudgePresetOptions): MeasurementAssertionHandle; ``` ## Test set exports ```ts theme={null} export default rows.map((row) => defineEval({ description: row.task, async test(t) { await t.send(row.prompt); }, }), ); ``` When you export an array, each item gets a stable ID: `file/0000`, `file/0001`, and so on. When you already have stable business keys, you can also default-export a `Record`. For example, the key `15193` in `swelancer.eval.ts` produces `swelancer/15193`. A key must be a non-empty single path segment; it cannot be `.` or `..`, and cannot contain `/`, `\\`, or control characters. Evals are ordered by key in lexicographic order. ### Reading material from ctx, and official usage accounting `defineMaterialMatch({ name, read, match, capture? })` defines a set of material read from ctx; import it from `niceeval` or `niceeval/expect`. The Adapter's `create` provides the application's own ctx. When registering in `check` or `closeQA`, NiceEval passes the current read-only ctx to `read`. `read` returns `{ state: "complete", items: [{ id, value }] }`, or a partial or unavailable state with a `reason`. Each item's `id` must be unique within the set, at most 128 UTF-8 bytes. `capture` controls how much material is saved: by default at most 256 items and 48 KiB, adjustable up to 16384 items and 4 MiB. `check(materialMatch)` returns a Boolean assertion that by default passes when at least one item satisfies the condition. `closeQA(materialMatch, question, options?)` returns a Judge-scored assertion: `question` cannot be empty and is at most 8 KiB; `options` is of type `JudgePresetOptions`. All selected material is given to the Judge at once in original order. When the material is complete but empty, the score is 0 and the model is not called. In Agent scenarios, `read` reads the tool calls (`toolCalls`) or events (`eventOccurrences`) NiceEval has organized from `AgentMatchContext`, then matches them with a ToolMatch or EventMatch respectively. `defineContextMatch({ name, read, match, capture? })` reads a single small value from ctx. `read` returns `{ state: "available", value }` or `{ state: "unavailable", reason }`. Paired with a BooleanMatch it yields a Boolean assertion; paired with a ScoreMatch it yields a scoring assertion. When the value is unavailable, the scoring logic is not called. `t.usage` reads the usage officially recorded by NiceEval, fixed at the moment it is read. Failed and retried calls, cache buckets, and the basis for cost calculation are all kept. * For an ordinary Adapter, the usage basis is every recorded call (`recorded-calls`). * For an Agent, the usage basis is the numbers reported on each send (`reported-sends`), viewable per Turn, Session, or Attempt. * Fields such as `usage.totalTokens` are NumericMaterial. Unknown values stay unknown and are never filled in as 0. `t.maxTokens(max)` limits total tokens. `t.maxCost(usd)` limits by USD cost: it prefers the actual charged amount, and a charge of 0 also counts; only when there is no actual charge does it use an estimate computed from prices you configured explicitly. `t.elapsedMs` is the milliseconds elapsed from the start of the Attempt to the moment it is read, and can be passed to `t.check(..., atMost(limit))`. The business moment of "first completion" still has to be judged with the business's own match condition. # defineExperiment: Experiment Configuration Source: https://niceeval.com/docs/reference/define-experiment Reference for defineExperiment fields, types, and default behavior. An Experiment decides what you run against, including the Agent under test, the model, and the run conditions. It is kept separate from Evals, so the same set of Evals can compare different configurations. For the complete approach, see [Write an Experiment](/docs/tutorials/write-experiment). ```ts theme={null} // experiments/local.ts import { defineExperiment } from "niceeval"; import { webAgent } from "../agents/web-agent.ts"; export default defineExperiment({ agent: webAgent({ baseUrl: "http://127.0.0.1:5188" }), }); ``` ## Experiment fields #### `models` ```ts theme={null} models?: Readonly>; ``` Selects a model for each named model slot of a custom Adapter; cannot be combined with the single-model shorthand. #### `description` ```ts theme={null} description?: string; ``` A one-sentence description shown in the view and CLI; purely informational, it does not affect scheduling or scoring. #### `agent` ```ts theme={null} agent?: Agent; ``` Shorthand for a conversational Adapter; exactly one of `agent` and `adapter` must be provided. #### `adapter` ```ts theme={null} adapter?: Adapter; ``` The Adapter implementation this Experiment uses. #### `model` ```ts theme={null} model?: string; ``` A single model (decided by the Experiment when the agent leaves it empty); omitted = use the agent's native default. To compare models, write several Experiment files instead of an array. #### `reasoningEffort` ```ts theme={null} reasoningEffort?: string; ``` The model's reasoning effort (such as "low"/"medium"/"high"; valid values depend on the specific model/adapter); omitted = use the agent's native default. Passed to the adapter and eval through ctx.reasoningEffort. #### `judgeRuntime` ```ts theme={null} judgeRuntime?: JudgeSelection; ``` This Experiment's Judge execution configuration. It overrides only the model / endpoint / credential selector / call budget; the rubric is owned by the Match, and the material and consumption threshold come from the Assertion. Each field resolves in the order Experiment → Eval → Config → built-in default. #### `flags` ```ts theme={null} flags?: globalThis.Record; ``` Experiment conditions (the feature flags in an A/B), declared by the Experiment file; must be a flat scalar object (strings, finite numbers, or booleans only, validated when defineExperiment parses it). Passed to the adapter through ctx.flags, exposed to the eval as t.flags, and copied verbatim into the result snapshot's ExperimentRunInfo.flags. #### `labels` ```ts theme={null} labels?: globalThis.Record; ``` Report grouping labels: the Experiment's coordinates on each comparison axis (such as `{ line: "codex", memory: "mempal" }`). Values are string | number (validated at parse time). The line between labels and `flags` is "does it change what happens in the attempt": labels are not passed to ctx / t (the agent and eval cannot see them) and do not take part in the comparability configuration (changing them does not invalidate existing results); they are only projected verbatim into the snapshot's `ExperimentRunInfo.labels` for report dimensions (`label()` / `numericLabel()`) to group by. The default report recognizes the `line` key: when any Experiment in the group declares it, scatter points are grouped by line and connected. See docs/feature/experiments/library.md "labels". #### `attempts` ```ts theme={null} attempts?: number; ``` How many times to repeat the same eval (each result counts as one attempt); defaults to 1 when omitted or when CLI `--attempts` overrides it. #### `earlyExit` ```ts theme={null} earlyExit?: boolean; ``` Within a repetition (attempts > 1), whether to skip the remaining repeats after one attempt passes; defaults to false when omitted (`attempts` run in full to measure the complete pass rate). Turn it on explicitly to save cost when you only want to know whether it can pass. #### `evals` ```ts theme={null} evals?: "*" | readonly string[] | ((e: EvalDescriptor) => boolean); ``` Which evals this Experiment covers: `"*"` for all, a string array matched by id prefix, or a custom predicate (which receives each discovered and fanned-out read-only `EvalDescriptor`, with no path / execution fields exposed); omitted is equivalent to `"*"`. The predicate is evaluated once per candidate eval of this invocation, and the result becomes the in-memory `selectedEvalIds` plan, not a filter called repeatedly at runtime (see docs/feature/eval/library.md "EvalDescriptor" and docs/feature/experiments/library.md "evals: iterate discovery results, choose your own"). #### `timeoutMs` ```ts theme={null} timeoutMs?: number; ``` Overrides the project-level / CLI per-attempt timeout (milliseconds), only for this Experiment. #### `sandbox` ```ts theme={null} sandbox?: SandboxLayer; ``` The Sandbox declaration layer this Experiment contributes. It is linked pairwise with the same field of each selected Eval; in each pair, exactly one side provides a template-bearing layer. #### `sandboxCache` ```ts theme={null} sandboxCache?: SandboxCacheConfig; ``` The Host's execution cache policy; unlike the Sandbox declaration layer, this field does not take part in identity computation. #### `plugins` ```ts theme={null} plugins?: readonly PluginInstance<"experiment">[]; ``` The list of Plugin instances this Experiment declares explicitly, normalized by `defineExperiment()`. #### `sandboxReuse` ```ts theme={null} sandboxReuse?: boolean; ``` Reuse Sandboxes within the same Run; such runs are isolated from history carry in both directions. #### `sharedState` ```ts theme={null} sharedState?: SharedStateConfig; ``` Declares shared external state this Experiment needs exclusively. Invocations with the same key in the same project Coordination domain are mutually exclusive from before Experiment/Sandbox setup until Sandbox teardown, provider finalizers, and Experiment teardown have all finished; waiters do not create a Sandbox first, and continue their own plan after acquiring the lease. It is not a state store, a transaction, or a cross-machine lock. #### `budget` ```ts theme={null} budget?: number; ``` This Experiment's estimated spend limit (USD). The scheduler accumulates the `estimatedCostUSD` of finished attempts; that value is computed from the model, token usage, and the runtime/config pricing table, independent of the observed `usage.costUSD` reported by the Provider / Adapter, which does not drive the budget. Once the estimate reaches the limit, the Experiment's remaining attempts that have not started are skipped and `run:budgetExceeded` is reported once (attempts already in flight still finish). #### `maxConcurrency` ```ts theme={null} maxConcurrency?: number; ``` This Experiment's own concurrency limit within this Invocation: the scheduler throttles only this Experiment's attempts; other Experiments in the batch are unaffected and still run under the global concurrency (CLI / env / config / Sandbox default). Use it to serialize Experiments with shared state (such as memory accumulated across evals: `maxConcurrency: 1` guarantees attempts run one by one in eval order), or to slow down an Experiment that hits provider limits. A slot shares the attempt's lifetime: it is held from before Sandbox creation until teardown and Sandbox destruction finish, and any wait along the way (including turn retry backoff) keeps holding it. `maxConcurrency: 1` is therefore a strict critical section that the next attempt of the same Experiment cannot enter early. It does not span Invocations; for shared external state across Invocations, declare `sharedState`. #### `classifyFailure` ```ts theme={null} classifyFailure?: AttemptFailureClassifier; ``` This Experiment's failure classifier: it recognizes your own shared-infrastructure causes of death that surface in third-party error shapes (such as connections refused by your own tunnel host), and returns `undefined` for "unknown, hand off to the rest of the chain". Failures in any per-attempt phase of this Experiment are all routed to it; on the send failure chain it runs before the adapter's `classifySendFailure`, because filtering by your own coordinates is more specific than the protocol's generic shape, so the scope axis wins when both claim the failure. The classifier must be fast, pure, and must not throw (a throw falls back to `undefined` and is swallowed); it declares only the decision axis and the `reason` word, and retry and gating policy belong to the executor. See docs/feature/error-classification/library.md "Experiment / eval authors: declaring the blast radius of a cause of death". #### `setup` ```ts theme={null} setup?: ExperimentHook; ``` The setup side of the Experiment-level lifecycle hook pair: at most once per run, on the host side, for host resources that are "one per Experiment, shared by all attempts" (tunnels, mock servers, license leases). Triggered by the first attempt of this Experiment that passes dispatch admission (memoized: concurrent attempts wait for the same result; not run when all results are carried in). setup returns no value; write products to module-level variables, and `teardown` and agent / sandbox hooks in the same file read them from the closure; the runner does not mediate values. If setup throws, every Attempt of this Experiment gets an `errored` Verdict (code `"experiment-setup-failed"`, phase `"experiment.setup"`); other Experiments in the batch are unaffected. The function body does not enter the fingerprint; after changing hook logic, use `--rerun all` to explicitly rerun everything. See docs/feature/experiments/architecture.md "Experiment-level lifecycle". #### `teardown` ```ts theme={null} teardown?: ExperimentHook; ``` The teardown side of the Experiment-level lifecycle hook pair: runs after all of this Experiment's attempts finish (also when the run is interrupted), if and only if execution reached the setup point. A setup throw does not exempt it (a half-initialized scene also needs cleanup; teardown should guard closure variables that may be unassigned); having no setup declared does not prevent it from firing; it is skipped when no attempt is dispatched. A throw or exceeding the 30s cleanup limit records only a run-level diagnostic (`experiment-teardown-failed`) and does not change the Verdict. # Standard Event Stream Reference Source: https://niceeval.com/docs/reference/events The ten StreamEvent variants: each one's fields, when it is emitted, and which assertions read it. Producing this stream is an Adapter's main job. An Adapter's `send` returns a `Turn`, and its `events: StreamEvent[]` is this turn's standard event stream; event assertions read their facts from it. `turn.calledTool(...)`, `turn.toolOrder(...)`, `turn.event(...)`, and `turn.succeeded()` all judge from this turn's events and the tool calls within them, and register an assertion directly when called. Translate what the agent did in this turn truthfully into the stream, and every event assertion works. ## Turn: the return value of send ```ts theme={null} interface Turn { events: StreamEvent[]; // this turn's events, in their actual order data?: JsonValue; // structured output, which t.check(...) can compare status: "completed" | "failed" | "waiting"; // waiting = paused for human input (HITL) usage?: Usage; // → maxTokens / maxCost / cost reports; full fields below } ``` `data` holds this turn's structured output. Fill it only when the application's answer itself is a structured object, such as extraction, classification, or form filling, so an Eval author can compare it with `t.check(turn.data, ...)`. An application that returns only text leaves `data` unset. Do not copy the raw response body or `message` text into it, and do not serialize structured output into `events`. Fill `usage` when you have it and leave it empty when you do not; do not estimate a number to fill it. The full fields of `usage` (the `Usage` type): #### `inputTokens` ```ts theme={null} inputTokens?: number; ``` Input tokens that missed the cache and are billed at full price; mutually exclusive with the two cache buckets. #### `outputTokens` ```ts theme={null} outputTokens?: number; ``` The number of output (completion) tokens. #### `cacheReadTokens` ```ts theme={null} cacheReadTokens?: number; ``` Input tokens served from a prompt-cache hit; a separate billing bucket, not included in inputTokens (omitted means the agent does not report it). #### `cacheCreationTokens` ```ts theme={null} cacheCreationTokens?: number; ``` Input tokens written to the prompt cache; a separate billing bucket, not included in inputTokens (omitted means the agent does not report it). #### `reasoningTokens` ```ts theme={null} reasoningTokens?: number; ``` The number of reasoning (thinking) tokens, a detail already included in outputTokens and listed separately for display; present only when the protocol actually provides it. #### `requests` ```ts theme={null} requests?: number; ``` The number of model requests that actually occurred. Omit it when the protocol does not provide a request count; never write 1 to fill it. #### `costUSD` ```ts theme={null} costUSD?: number; ``` The actual USD cost measured by the gateway/adapter (it can only be brought back explicitly through `Turn.usage.costUSD`, and is never derived from token usage or OTel spans). It and the top-level `estimatedCostUSD` (a price-table estimate) are two independent facts under a one-way field contract: this field stores only the observed value; `estimatedCostUSD` always equals the estimate from `estimateCost(model, usage, pricing)`, computed independently even when an observed value exists. Neither overrides or backs up the other; an observed value never replaces or triggers the estimate. > How cost is calculated: `Usage.costUSD` records only the USD cost actually returned by a Provider or Adapter; it is never derived from token counts, a model catalog, or a local price table. Whether or not that value exists, the Runner separately computes an estimated cost, `estimatedCostUSD`, from the `model`, the reported token usage, and the configured price table. > > The Experiment budget and `t.maxCost()` look at this estimate, not `Usage.costUSD`. Costs in reports are computed only from a `PricingProfile` you specify explicitly and the recorded usage; they do not read the Runner's estimate either. ## StreamEvent variants at a glance `StreamEvent` has ten variants, listed field by field below. For which assertions read them and what to watch for when using them, see the "Event table" and "Event details" sections that follow. #### `message` ```ts theme={null} { type: "message"; role: "assistant"; text: string; loc?: SourceLoc } ``` #### `message` ```ts theme={null} { type: "message"; role: "user"; text: string; loc?: SourceLoc; sourceOrder?: number } ``` #### `operation.started` ```ts theme={null} { type: "operation.started"; operationId: string; operation: | { kind: "tool"; name: string; input: JsonValue; tool?: ToolName; /** * The Adapter's protocol-level command / not-command classification. Temporarily optional for * third-party Adapters that have not migrated; when absent, core must not fabricate it from the name, input, or shell text. */ command?: CommandProjection; } | { kind: "subagent"; name: string; remoteUrl?: string }; } ``` #### `operation.finished` ```ts theme={null} { type: "operation.finished"; operationId: string; kind: "tool"; output?: JsonValue; status: "completed" | "failed" | "rejected"; } ``` #### `operation.finished` ```ts theme={null} { type: "operation.finished"; operationId: string; kind: "subagent"; output?: JsonValue; status: "completed" | "failed"; } ``` #### `skill.loaded` ```ts theme={null} { type: "skill.loaded"; skill: string; operationId?: string } ``` #### `input.requested` ```ts theme={null} { type: "input.requested"; request: InputRequest } ``` #### `thinking` ```ts theme={null} { type: "thinking"; text: string } ``` #### `context.injected` ```ts theme={null} { type: "context.injected"; text: string; source?: string } ``` #### `compaction` ```ts theme={null} { type: "compaction"; reason?: string } ``` #### `error` ```ts theme={null} { type: "error"; message: string } ``` ## Event table | Event | What it says | Assertion / API that reads it | | - | - | - | | `message` | The agent (or user) said something | `turn.message`, `turn.event(eventMatch("message", ...))`, Judge material | | `operation.started` (`kind: "tool"`) | Starts a tool call | `turn.calledTool(...)`, `turn.toolOrder(...)`, `turn.event(...)` | | `operation.finished` (`kind: "tool"`) | The result of that tool call | `ToolMatch` with a status condition | | `operation.started` (`kind: "subagent"`) | Delegates to a subagent | Kept in raw events and reports; there is no subagent-specific assertion | | `operation.finished` (`kind: "subagent"`) | The subagent returns | Kept in raw events and reports; a failure counts toward `noFailedActions()` | | `input.requested` | Stops to wait for human input (HITL) | `t.check(turn.status, equals("waiting"))`, `t.requireInputRequest()` | | `thinking` | Thinking text | Shown in the view; check it with `t.check(turn.events, ...)` when needed | | `compaction` | Context was compacted | Shown in the view; check it with `t.check(turn.events, ...)` when needed | | `error` | This turn failed | Shown in the view; check it with `t.check(turn.events, ...)` when needed | Event assertions have three entry points: `event(match)`, `notEvent(match)`, and `eventOrder(matches)`, all of which take a match condition built with `eventMatch(...)`. `eventMatch` supports three events: `message`, tool `operation.started`, and tool `operation.finished`. To require a number of occurrences, use the match condition's own methods such as `.atLeast()` and `.exactly()`. For other events, treat `turn.events` as an ordinary value and check it with `check(turn.events, valueMatch)`. ## Event details ### `message` — what was said ```ts theme={null} { type: "message", role: "assistant" | "user", text: string } ``` Each segment of assistant text produces one `message` with `role: "assistant"`. A tool result is not an assistant message; do not wrap tool output in `message`, or `t.reply` will read the wrong content. NiceEval records user-input `message` events automatically, so the Adapter does not need to emit them. ### Tool `operation.started` / `operation.finished` — which tool ran and what happened ```ts theme={null} { type: "operation.started", operationId: string, operation: { kind: "tool", name: string, input: JsonValue, tool?: ToolName, command?: CommandProjection } } { type: "operation.finished", operationId: string, kind: "tool", output?: JsonValue, status: "completed" | "failed" | "rejected" } ``` * Every tool `operation.started` needs a tool `operation.finished` with the same `operationId`; concurrent calls are matched up by this ID. Use the ID already in the agent's return when there is one, such as AI SDK's `toolCallId` or Anthropic's `tool_use.id`; generate one by order only when there is none. * Set `status` to what actually happened: `"failed"` when the tool execution failed, `"rejected"` when a person rejected it. Eval authors use `ToolMatch` status conditions to tell the two apart, so do not mix them up. * Set `name` to the tool's original name. * With only started and no finished yet, the call's status is `pending` and its output is recorded as unavailable. A finished event without `output` also means the output is unavailable. Do not write missing output as empty JSON, and do not let it look like an ordinary mismatch. #### Pass tool events to `ToolMatch` The same `shell` tool might first run `git status` and then `pnpm test`. The tool name only says which kind of tool it is. To tell the specific commands apart, Eval authors match on the normalized command information (command projection): ```ts theme={null} import { commandMatch } from "niceeval/expect"; turn.calledTool(commandMatch("pnpm", { argsStart: ["test"] })); ``` `ToolMatch` compares one tool call at a time. For the complete filter conditions, and the JSON, path, output, and counting rules, see [Scoped assertions](https://github.com/NiceEval/NiceEval/blob/main/docs/feature/assertions/library/scoped-assertions.md). When the native protocol directly provides a command's structured argv, generate the command information with the constructor exported from `niceeval/adapter`: ```ts theme={null} import { commandProjection } from "niceeval/adapter"; events.push({ type: "operation.started", operationId: call.id, operation: { kind: "tool", name: call.name, input: call.input, command: commandProjection({ state: "available", executable: call.executable, args: call.args, }), }, }); ``` `commandProjection()` keeps the original arguments the Adapter confirmed and normalizes them into the `logical-command/v1` format by NiceEval's shared rules. For example, `pnpm exec niceeval view` is matched exactly by `commandMatch("niceeval", { argsStart: ["view"] })`. Mark the original command available (`state: "available"`) only when the native protocol itself provides argv, or when one command can be split out unambiguously by the protocol's grammar. When the protocol provides only shell source, or the content is truncated or redacted, use `opaqueCommandProjection(reason)`. When you can confirm the call is not a command, use `notCommandProjection()`. When you cannot tell whether it is a command, downgrade actions coverage; do not guess from the tool name, input, or shell text. ### Subagent `operation.started` / `operation.finished` — who was delegated to ```ts theme={null} { type: "operation.started", operationId: string, operation: { kind: "subagent", name: string, remoteUrl?: string } } { type: "operation.finished", operationId: string, kind: "subagent", output?: JsonValue, status: "completed" | "failed" } ``` When the system under test delegates a task to a subagent and waits for it to return, emit this pair; the `operationId` pairing rule is the same as above. They stay in the raw event stream and reports. To check them, use an ordinary value match on `turn.events`; the tool matching in `eventMatch(...)` does not treat a subagent as a tool call. ### `input.requested` — stop and wait for a person (HITL) ```ts theme={null} { type: "input.requested", request: { id?: string, action?: string, // the action where it paused (such as a tool name) input?: JsonValue, // the action's input prompt?: string, // the question for the person options?: { id: string, label?: string }[], // choices (approve / deny…) } } ``` When the agent stops to wait for a person, each question awaiting an answer produces one `input.requested`, and the Turn's `status` is `"waiting"`. `t.requireInputRequest(filter)` matches `request` field by field, so fill in as many fields as you can, or the Eval cannot select it. For how to wire it up, see the [HITL section of the connection tutorial](/docs/tutorials/connect-your-agent). ### `thinking` / `compaction` / `error` ```ts theme={null} { type: "thinking", text: string } { type: "compaction", reason?: string } // context compaction; emitted when the Adapter parses it { type: "error", message: string } ``` Emit them when they exist; do not make them up when they do not. `compaction` mostly comes from coding agent CLIs, which compact automatically when the context fills up. These events are mainly for reports and diagnostics; when you need to assert on them, pass `turn.events` to `check` as an ordinary value. ## Three rules for mapping events 1. **Keep the real order**: event order is the order things happened. `toolOrder` and `eventOrder` match different calls from front to back, so the wrong order gives the wrong assertion result. `eventOrder` can only order events that `eventMatch` supports. 2. **Pair with `operationId`**: every started needs a finished with the same `kind` and ID. Only with complete pairs can NiceEval attribute status and input to the same tool call. 3. **Declare completeness honestly**: official converters declare event coverage (evidence coverage) according to what the Adapter can actually do. When the relevant input or action coverage is incomplete, a negative assertion (such as "did not call a tool") results in `unavailable`; "not seen" is never treated as "did not happen." Manual mappings must also declare coverage honestly; see the [Capabilities reference](/docs/reference/capabilities). ## A complete mapping example When the agent's return includes step records, the mapping is a simple loop: ```ts theme={null} import type { StreamEvent } from "niceeval"; function toStreamEvents(body: MyAgentResponse): StreamEvent[] { const events: StreamEvent[] = []; for (const step of body.steps) { if (step.type === "tool_call") { events.push({ type: "operation.started", operationId: step.id, operation: { kind: "tool", name: step.tool, input: step.args }, }); events.push({ type: "operation.finished", operationId: step.id, kind: "tool", output: step.result, status: step.error ? "failed" : "completed", }); } if (step.type === "text") events.push({ type: "message", role: "assistant", text: step.text }); } return events; } ``` ## Related reading * [Connect your agent](/docs/tutorials/connect-your-agent) — the tutorial for getting it working from scratch. * [Capabilities](/docs/reference/capabilities) — what declaring "the event stream is complete" means. * [Authoring Evals](/docs/tutorials/authoring) — the full set of assertions that read this stream. # niceeval/expect Match Reference Source: https://niceeval.com/docs/reference/expect Pure Match factories from niceeval/expect: compose text, structure, and conditions, then register an Assertion with t.check. `niceeval/expect` provides only pure Match factories. A Match compares one candidate value; it does not record a result or decide a Verdict or score. `t.check(value, match)` reads `value` when called and registers an Assertion directly; the returned handle only configures that same entry. ```ts theme={null} import { and, excludes, includes, pattern } from "niceeval/expect"; ``` ## How to use it ```ts theme={null} t.check(t.reply, includes("confirmed")); t.check(t.reply, and(pattern(/order #\d+/i), excludes("cancelled"))); const config = await t.check(rawConfig, pattern(/runtime/)).orStop(); ``` | Entry point | Purpose | | - | - | | `t.check(value, match)` | Takes exactly two arguments and registers an Assertion when called; a failure does not stop later code | | `await handle.orStop()` | An async barrier on the same handle; stops the current continuation on mismatch or below | | `handle.score(n)` / `t.score(n)` | Lets this item contribute a score in a scored Eval, or registers a contribution directly | ## Matchers The signature and description for every matcher below are generated from the `niceeval/expect` source and stay in sync with the current implementation. #### `and` ```ts theme={null} export function and( first: BooleanMatch, ...rest: readonly BooleanMatch[] ): BooleanMatch { ... } ``` #### `or` ```ts theme={null} export function or( first: BooleanMatch, ...rest: readonly BooleanMatch[] ): BooleanMatch { ... } ``` #### `not` ```ts theme={null} export function not(match: BooleanMatch): BooleanMatch { ... } ``` #### `includes` ```ts theme={null} export function includes(text: string, options?: TextMatchOptions): BooleanMatch { ... } ``` #### `excludes` ```ts theme={null} export function excludes(text: string, options?: TextMatchOptions): BooleanMatch { ... } ``` #### `pattern` ```ts theme={null} export function pattern(expression: RegExp, options?: TextMatchOptions): BooleanMatch { ... } ``` #### `similarity` ```ts theme={null} export function similarity(expected: string): ScoreMatch { ... } ``` Normalized Levenshtein similarity; it is a continuous score and carries no default threshold or Verdict policy. #### `includesUrl` ```ts theme={null} export function includesUrl(min = 1): BooleanMatch { ... } ``` A plain-text matcher that requires at least min distinct http(s) URLs. #### `hasSections` ```ts theme={null} export function hasSections(min = 2): BooleanMatch { ... } ``` A plain-text matcher that requires at least min Markdown headings. #### `isDefined` ```ts theme={null} export function isDefined(label?: string): BooleanMatch> { ... } ``` #### `isTrue` ```ts theme={null} export function isTrue(label?: string): BooleanMatch { ... } ``` A refinement matcher for value === true. #### `isFalse` ```ts theme={null} export function isFalse(label?: string): BooleanMatch { ... } ``` A refinement matcher for value === false. #### `commandSucceeded` ```ts theme={null} export function commandSucceeded(): BooleanMatch { ... } ``` An exitCode === 0 matcher for CommandResult-like candidates; it does not read stdout / stderr. #### `equals` ```ts theme={null} export function equals(expected: T): BooleanMatch { ... } ``` #### `matches` ```ts theme={null} export function matches( schema: S, ): BooleanMatch, "value"> { ... } ``` #### `satisfies` ```ts theme={null} export function satisfies( label: string, predicate: (value: T) => boolean | Promise, ): BooleanMatch | BooleanMatch { ... } ``` #### `defineValueMatch` ```ts theme={null} export function defineValueMatch(spec: { readonly name: string; readonly evaluate: (value: T) => boolean | { readonly state: "unavailable"; readonly reason: string } | Promise; }): BooleanMatch | BooleanMatch { ... } ``` #### `defineScoreMatch` ```ts theme={null} export function defineScoreMatch(spec: ManagedScoreMatchOptions | { readonly name: string; readonly score: (value: T) => number | Promise; }): ScoreMatch { ... } ``` #### `eventMatch` ```ts theme={null} export function eventMatch( type: K, options?: EventOptionsByType[K], ): EventMatch> { ... } ``` #### `mapValue` ```ts theme={null} export function mapValue( label: string, project: (value: T) => U, match: Match, "value"> & { readonly kind: "boolean" }, ): BooleanMatch { ... } ``` Project a single value without replacing the original subject's refinement. #### `mapEach` ```ts theme={null} export function mapEach>( project: (value: T) => U, aggregate: Match>, "value"> & { readonly kind: "boolean" }, ): BooleanMatch, Subject> { ... } ``` Project each complete item, retaining its material identity and order. #### `filterWhere` ```ts theme={null} export function filterWhere>( selector: BooleanMatch, aggregate: BooleanMatch, CollectionValue>, ): BooleanMatch { ... } ``` Select only definite matches; an unknown selector makes the aggregate unavailable. #### `countWhere` ```ts theme={null} export function countWhere>( item: BooleanMatch>, count: Match & { readonly kind: "boolean" }, ): BooleanMatch { ... } ``` Compare an exact match count only after every item in a complete collection is known. #### `defineMaterialMatch` ```ts theme={null} export function defineMaterialMatch(definition: unknown): ContextualMatch { ... } ``` #### `defineContextMatch` ```ts theme={null} export function defineContextMatch(definition: unknown): ContextualMatch { ... } ``` ### Match tool calls `ToolMatch` compares one `LogicalToolOccurrence`, and expresses the occurrence count with methods such as `.exactly(n)` and `.atLeast(n)`. `calledTool` accepts a name, an unquantified `ToolMatch`, or a positive occurrence Match; `notCalledTool` uses an unquantified selector to express zero matches. ```ts theme={null} import { commandMatch, jsonMatch, referencesAnyPath, toolMatch, } from "niceeval/expect"; turn.calledTool( toolMatch("get_weather", { input: jsonMatch({ city: "Taipei", unit: "celsius" }), status: "completed", }).exactly(1), ).label("Query weather precisely"); turn.calledTool( toolMatch("read_file", { input: referencesAnyPath([".env", "secrets/**"]), }), ).label("Read a restricted path"); turn.notCalledTool(commandMatch("rm", { argsStart: ["-rf"] })); ``` Use `jsonMatch` for ordinary JSON, `referencesAnyPath` for paths, and `commandMatch` for command tokens. When output is absent, material is incomplete, or HITL has not finished, the Match becomes unavailable; it does not pretend to be an ordinary mismatch. For complete signatures, material states, and three-valued counting rules, see [Scoped assertions](https://github.com/NiceEval/NiceEval/blob/main/docs/feature/assertions/library/scoped-assertions.md). ### Common usage examples ```ts theme={null} t.check(t.reply, includes("Paris")); t.check(t.reply, pattern(/order #\d+/i)); t.check(turn.data, equals({ intent: "refund" })); t.check(turn.data, matches(OrderSchema)); t.check(turn.data, satisfies("returns an array", Array.isArray)); t.check(t.reply, similarity("The answer explains the refund window")).gate(0.8); ``` ## Composition and three states ```ts theme={null} t.check(t.reply, and(includes("required"), excludes("forbidden"))); t.check(t.reply, or(includes("approved"), includes("accepted"))); ``` Matches within the same `and` / `or` must belong to the same domain. The internal result of a Boolean Match is `matched`, `mismatched`, or `unavailable`. `or` returns `unavailable` only when no child matches and at least one child's evidence is incomplete; it does not treat unknown as negative. A Match itself holds no Verdict, scoring, or control flow; `.gate(minimum)`, `.score(n)`, and `.orStop(minimum)` for a continuous measurement all belong to the registered AssertionHandle. The following lists stable fields on Match values themselves. Brands and evaluators are module-private implementation details, not author API. ### `Match` #### `domain` ```ts theme={null} readonly domain: D; ``` #### `name` ```ts theme={null} readonly name: string; ``` ### `BooleanMatch` #### `kind` ```ts theme={null} readonly kind: "boolean"; ``` #### `[matchRefinementBrand]` ```ts theme={null} readonly [matchRefinementBrand]: () => R; ``` ### `ScoreMatch` #### `kind` ```ts theme={null} readonly kind: "score"; ``` ## Ordering conditions in a collection `inOrder` combines 2–64 ordinary value Matches into a collection Match, which you pass to `t.check` or to an application's collection assertion method. It accepts a read-only array or a `MaterialCollection` with a completeness state, and looks for a strictly increasing subsequence by original position; the items need not be adjacent. ```ts theme={null} import { equals, inOrder, not, or } from "niceeval/expect"; t.check([1, 0, 2], inOrder([equals(1), equals(2)])).gate(); t.check([2, 1], not(inOrder([equals(1), equals(2)]))).gate(); t.check([3, 4], or( inOrder([equals(1), equals(2)]), inOrder([equals(3), equals(4)]), )).gate(); ``` The same item cannot satisfy two steps, and each step can use `and` to constrain the same fact. The application is responsible for providing the formal commit order; a request being sent does not mean the operation was adopted or finished executing. When you only need both parties to have spoken, check existence separately. An incomplete collection returns undecidable. A complete collection passes when there is known ordering evidence, fails when no ordering is possible, and is undecidable when it can only hold by depending on unknown items. Diagnostics keep the original positions, material IDs, and step judgments. Ordering combinations of ToolMatch and EventMatch keep their own Agent scope and cannot be mixed with ordinary value steps. ## Custom matchers ```ts theme={null} import { defineValueMatch } from "niceeval/expect"; const validEmail = () => defineValueMatch({ name: "valid email", evaluate(value) { return value.includes("@"); }, }); t.check(t.reply, validEmail()); ``` Use `defineValueMatch` for a custom Boolean Match and `defineScoreMatch` for a continuous score. Both implement only a single-candidate comparison; reading files, scanning events, deciding coverage, and changing a Verdict remain the responsibility of Assertion registration entry points and the Runner. # Official Adapter Overview Source: https://niceeval.com/docs/reference/official-adapters NiceEval's built-in Sandbox and non-Sandbox Adapters, their authentication, how to install MCP servers, Skills, and plugins for Sandbox Adapters, and how to use an Agent's official configuration file. [NiceEval](https://niceeval.com/) ships several official Adapters—factory functions exported from `niceeval/adapter`—in two families based on whether the system under test needs an isolated workspace. **Sandbox Adapters** (`claude-code` / `codex` / `bub`) run a coding-Agent CLI in a Docker or cloud Sandbox and can install MCP servers, Skills, and Python plugins. **Non-Sandbox Adapters** connect non-intrusively to an HTTP service that is already running, or save event-stream mapping work when you write an Adapter yourself. This page is organized by family and individual Adapter, and focuses on each Adapter's configuration. For choosing one and getting the first Eval working, see [Connect your Agent](/docs/tutorials/connect-your-agent). ## Sandbox Adapters All three built-in Sandbox Agents use `defineSandboxAgent`, authenticate through environment variables that factory options can override, and support installing extensions during Sandbox `setup`. For running a built-in Sandbox Agent, directory structure, and writing a custom Sandbox Adapter, see [Sandbox Agent](/docs/tutorials/sandbox-agent). This page covers only what each Adapter can install and how to write its options. On this page, `settingsFile` and `configFile` are both resolved relative to the NiceEval project root. The project root is the current working directory when `niceeval` runs—the directory containing `niceeval.config.ts`—not the directory containing the Eval or Experiment file. For example, when an Experiment is at `experiments/web/no-search.ts` and the configuration is at `configs/codex/no-web.toml`, still write `configFile: "configs/codex/no-web.toml"`. ### claude-code * **Authentication**: `ANTHROPIC_API_KEY`, which the `apiKey` factory option can override; optionally `ANTHROPIC_BASE_URL`, through the `baseUrl` factory option. * **Install an MCP server**: use `mcpServers`. During `setup`, it writes to the top-level `mcpServers` field in the user-level `~/.claude.json` in the Sandbox. The two shapes are distinguished by fields. A local stdio process uses `command`, optionally with `args` / `env`. A remote Streamable HTTP endpoint uses `url`, optionally with `headers`, which are written verbatim to request headers and commonly contain `Authorization`; it is written as a `{ "type": "http", "url": …, "headers": … }` entry. The `url` must be reachable inside the Sandbox. If the service runs on your machine, first expose it as a public address through a tunnel such as cloudflared or tailscale. * **Install a Skill**: use `skills: SkillSpec[]`. A local Skill is `{ kind: "local", path }`, a file or directory read from the project root. A Repo Skill is `{ kind: "repo", source, ref, skills }`, which can pin a commit or tag and enable only part of a multi-Skill repository. They are installed at project-level `.claude/skills//` in the Sandbox, where the claude CLI discovers them natively. The Adapter normalizes a native `Skill` invocation into a `skill.loaded` raw event instead of also recording it as a tool call. The public assert-first API currently has no dedicated Skill assertion, and it cannot be disguised as `calledTool("Skill", ...)`. * **Install a native Plugin**: use `plugins: ClaudeCodePluginSpec[]`. Each item declares a Marketplace connection (`name` / `source` / optional `ref`) and the Plugin name inside it. This type belongs only to claude-code and cannot be passed to codex. An Experiment that reuses a Sandbox needs no extra residual cleanup: before every Attempt, the named Marketplace registration and installed Plugin are reinstalled from the declared `source` / `ref`. * **Official configuration file**: `settingsFile` is a local project path on the machine running NiceEval, not a path inside the Sandbox. It points to a complete Claude Code `settings.json`. The path is relative to the project root and accepts only an ordinary relative path or a `./` prefix. `..`, an absolute path, `~`, and a symlink that resolves outside the project root all cause an error. The Adapter reads the file locally, uploads it, and replaces the otherwise-empty user-level `~/.claude/settings.json` in the Sandbox verbatim. It does not inherit host configuration, deep-merge, or reserialize. `model` and `env` belong to the Experiment and Adapter. If they appear in the file, setup errors and names the conflicting key. Keep secrets in environment variables, not the configuration file. * **Post-install scripts**: `postSetup: SandboxCommand[]` runs your commands in array order inside the Sandbox after settings are written and MCP servers, Skills, and Plugins are all installed. A typical use is a Plugin's own setup script, such as one that registers a Hook in global configuration. This kind of script must wait until its installed files are ready. Its paired cleanup, `preTeardown: SandboxCommand[]`, runs in reverse order before the Agent's own teardown step, and runs only if execution has reached the point where `postSetup` ran. A command that throws is an infrastructure error—the Attempt is recorded as `errored`—not the Agent failing the task. * **Tracing**: the claude CLI's beta native telemetry (`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA`). Spans carry only structure and timing. For details, see [OTel integration](/docs/tutorials/connect-otel). For example, use `configs/claude-code/no-web.json` to disable built-in web search: ```json theme={null} { "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "deny": ["WebSearch", "WebFetch"] } } ``` ```ts theme={null} import { defineExperiment } from "niceeval"; import { claudeCodeAgent } from "niceeval/adapter"; import { dockerSandbox } from "niceeval/sandbox"; export default defineExperiment({ agent: claudeCodeAgent({ mcpServers: [ { name: "browser", command: "npx", args: ["-y", "@anthropic/mcp-browser"] }, ], skills: [ { kind: "repo", source: "Effect-TS/skills", ref: "8f3c1a2", skills: ["effect"] }, { kind: "local", path: "skills/repository-guide.md" }, ], plugins: [ { marketplace: { name: "acme", source: "acme/claude-code-plugins", ref: "v1.3.0" }, name: "safe-shell", }, ], settingsFile: "configs/claude-code/no-web.json", }), model: "claude-sonnet-4-6", sandbox: dockerSandbox({ source: { type: "image", image: "node:24-slim" } }), }); ``` ### codex * **Authentication**: `CODEX_API_KEY`, which the `apiKey` factory option can override. Optionally, use `CODEX_BASE_URL` through the `baseUrl` factory option for an OpenAI-compatible proxy. * **Agent process environment**: `env` is injected into every `codex exec` and `codex exec resume`. Session lifecycle Hooks started by Codex, dynamic MCP request headers, and command subprocesses inherit it. Values are not concatenated into shell text or written to the installation manifest, and potential sensitive values are redacted from timing, execution, and error evidence. `CODEX_API_KEY` still comes from `apiKey` or the host environment and overrides the same key in `env`. `PATH` is managed by the Sandbox and cannot be declared through `env`; its presence errors at `codexAgent()` call time. Use the Sandbox `pathPrepend` instead; see [Sandbox Providers](/docs/tutorials/sandbox-providers). * **Install an MCP server**: use `mcpServers`. During `setup`, it appends a `[mcp_servers.]` section to `~/.codex/config.toml`. The two shapes are distinguished by fields. A local stdio process uses `command`, optionally with `args` / `env`. A remote Streamable HTTP endpoint uses `url`, optionally with `headers`, written as a `[mcp_servers..http_headers]` subtable. The `url` must be reachable inside the Sandbox. If the service runs on your machine, first expose it as a public address through a tunnel such as cloudflared or tailscale. Use plural `mcp_servers`: the codex CLI silently ignores singular `[mcp_server.x]`, so MCP never attaches and no error appears. Check with `codex mcp list`. * **Install a Skill**: use `skills: SkillSpec[]`, the same type as claude-code. They are installed in `.agents/skills//`, and a discovery instruction is written to AGENTS.md. Codex has no native Skill tool like claude-code, so installing a file alone does not make it read it. To assert whether it was used, check whether it actually ran the shell command that reads the file; there is no direct tool call to assert. * **Install a native Plugin**: use `plugins: CodexPluginSpec[]`. It declares the Marketplace connection (`name` / `source` / optional `ref` / optional `sparse`) and the Plugin name within it. `sparse` is an array of paths such as `[".agents", "plugins/repo-map"]`; each adds `--sparse ` to `codex plugin marketplace add`, so a large repository fetches only the paths a Plugin needs without changing the installed result. This type belongs only to codex and cannot be passed to claude-code. An Experiment that reuses a Sandbox needs no extra residual cleanup: before every Attempt, the named Marketplace registration and installed Plugin are reinstalled from the declared `source` / `ref`. * **Official configuration file**: `configFile` is a local project path on the machine running NiceEval, not a path inside the Sandbox. It points to a complete Codex `config.toml`. The path is relative to the project root and accepts only an ordinary relative path or a `./` prefix. `..`, an absolute path, `~`, and a symlink that resolves outside the project root all cause an error. The Adapter reads the file locally, uploads it, and replaces the otherwise-empty user-level `~/.codex/config.toml` in the Sandbox verbatim. It does not inherit host configuration, concatenate, deep-merge, or parse and rewrite. `model`, `model_provider`, `model_providers`, `model_reasoning_effort`, `mcp_servers`, and `otel` belong to the Experiment and Adapter. If they appear in the file, setup errors and names the conflicting key. Keep secrets in environment variables, not the configuration file. * **Post-install scripts**: `postSetup: SandboxCommand[]` has the same semantics as claude-code. It runs your commands in order in the Sandbox after all installation steps finish, so it is suited to a Plugin's own setup script. Its paired `preTeardown: SandboxCommand[]` cleans up in reverse order before Agent teardown, and runs only if execution has reached the point where `postSetup` ran. A Hook registered by a script into Codex global configuration does not need interactive trust confirmation: at runtime, `codex exec` has already bypassed the Hook trust gate, so the Hook takes effect directly. * **Tracing**: built in and configured through the `[otel.trace_exporter.otlp-http]` section of `config.toml`, using `http/json`. For example, use `configs/codex/no-web.toml` to disable built-in web search: ```toml theme={null} #:schema https://developers.openai.com/codex/config-schema.json web_search = "disabled" ``` ```ts theme={null} import { defineExperiment } from "niceeval"; import { codexAgent } from "niceeval/adapter"; import { dockerSandbox } from "niceeval/sandbox"; const memorySpace = "memorybench-nowledge"; export default defineExperiment({ agent: codexAgent({ env: { NMEM_SPACE: memorySpace }, mcpServers: [ { name: "browser", command: "npx", args: ["-y", "@anthropic/mcp-browser"] }, { name: "team-memory", url: "https://mem.example.com/mcp/", headers: { Authorization: `Bearer ${process.env.MEM_API_KEY}` } }, ], skills: [{ kind: "repo", source: "Effect-TS/skills", ref: "8f3c1a2", skills: ["effect"] }], plugins: [ { marketplace: { name: "acme", source: "acme/codex-plugins", ref: "8f3c1a2" }, name: "repo-map", }, ], configFile: "configs/codex/no-web.toml", }), flags: { memorySpace }, model: "gpt-5.4", sandbox: dockerSandbox({ source: { type: "image", image: "node:24-slim" } }), }); ``` ### bub * **Authentication**: `BUB_API_KEY` plus `BUB_API_BASE` for an OpenAI-compatible proxy. The `apiKey` / `apiBase` factory options can override them. * **Install a Skill**: use `skills: SkillSpec[]`, the same type as the other two Adapters. They are installed in `.agents/skills//`, with a discovery instruction in AGENTS.md. * **Install a Plugin**: use `pythonPlugins: PythonPluginSpec[]`. `{ package }` can be a PyPI package, version specifier, or git URL. During `setup`, it becomes `uv tool install … --with `. This type belongs only to bub. The package set enters the installation checkpoint key, so two variants with different Plugins never reuse the same installation cache. * **Prebuilt Bub**: NiceEval's E2B recipe includes Bub, the OTel Plugin, and the Python Plugin set in an installation fingerprint. The Adapter reuses only an environment with an exactly matching fingerprint. Simply having `bub` on `PATH` is not proof of compatibility. For the build entry point, see [Prebuilt Sandbox environments · Build on the official baselines](/docs/tutorials/prebuilt-sandbox#continue-building-from-the-official-baseline). * bub has no `mcpServers`. MCP belongs only to Adapters that support it, and this configuration has no such field. * **Post-install scripts**: `postSetup: SandboxCommand[]` has the same semantics as the other two Adapters. It runs your commands in order in the Sandbox after all installation steps finish. Its paired `preTeardown: SandboxCommand[]` cleans up in reverse order before Agent teardown, and runs only if execution has reached the point where `postSetup` ran. * **Installation method**: `uv tool install`, a PyPI package rather than an npm package. The first installation creates a checkpoint cache that speeds later Sandboxes. * **Tracing**: built in, injected through environment variables, using `http/protobuf`. ```ts theme={null} import { defineExperiment } from "niceeval"; import { bubAgent } from "niceeval/adapter"; import { dockerSandbox } from "niceeval/sandbox"; export default defineExperiment({ agent: bubAgent({ skills: [{ kind: "local", path: "skills/repository-guide.md" }], pythonPlugins: [{ package: "bub-plugin-memory==1.3.0" }], }), model: "gpt-5.4", sandbox: dockerSandbox({ source: { type: "image", image: "node:24-slim" } }), }); ``` ### Compare the three Sandbox Adapters | | claude-code | codex | bub | | - | - | - | - | | Authentication environment variable | `ANTHROPIC_API_KEY` | `CODEX_API_KEY` + optional `CODEX_BASE_URL` | `BUB_API_KEY` + `BUB_API_BASE` | | Additional Agent process environment | ❌ | ✅ `env` | ❌ | | MCP server | ✅ `mcpServers` (stdio + HTTP) | ✅ `mcpServers` (stdio + HTTP) | ❌ | | Skill | ✅ `skills: SkillSpec[]` (native discovery) | ✅ `skills: SkillSpec[]` (+ discovery instruction) | ✅ `skills: SkillSpec[]` (+ discovery instruction) | | Native Plugin | ✅ `plugins: ClaudeCodePluginSpec[]` | ✅ `plugins: CodexPluginSpec[]` | ❌ | | Official configuration file | ✅ `settingsFile` (complete settings.json) | ✅ `configFile` (complete config.toml) | ❌ | | Python Plugin | ❌ | ❌ | ✅ `pythonPlugins: PythonPluginSpec[]` | | Post-install scripts | ✅ `postSetup` / `preTeardown` | ✅ `postSetup` / `preTeardown` | ✅ `postSetup` / `preTeardown` | | Tracing | ✅ (beta, structure and timing only) | ✅ | ✅ | | Installation method | npm global package (the image needs Node) | Native package with its runtime included (unpack and use; the image needs no Node) | `uv tool install` (PyPI) | What was installed can be verified. At the end of `setup`, the Adapter hands an installation manifest to the Runner, which stores it as the Attempt Artifact `agent-setup.json`. Read it in the library with `attempt.agentSetup()`. The manifest never reaches Sandbox disk, so the Agent cannot see it. It records only sources, refs, Skill and Plugin names, resolved versions, and an official configuration file's project-relative path and SHA-256. It does not preserve configuration contents, API keys, or environment-variable values. MCP servers likewise record only non-sensitive fields: stdio records `name` / `command` / `args`, not `env`; HTTP records `name` / `url`, not `headers`. ## Non-Sandbox Adapters When the system under test does not need an isolated workspace—an already-deployed HTTP service or a home-grown Agent loop—use a non-Sandbox Adapter for a non-intrusive connection. Docker is not required. * **`uiMessageStreamAgent`**: built in, connects non-intrusively to an AI SDK `useChat` backend HTTP endpoint with zero mapping, including HITL. ```ts theme={null} import { uiMessageStreamAgent } from "niceeval/adapter"; export default uiMessageStreamAgent({ name: "my-assistant", url: "http://localhost:3000/api/chat", body: (ctx) => ({ model: ctx.model }), }); ``` * **SDK event-stream converters** (`createClaudeSdkEventStream` / `createPiAgentEventStream` / `createCodexThreadEventStream`): for hand-written SDK Adapters. NiceEval already provides the mapping from native frames to standard events. You only need to add the code that requests the application interface, then use `driveFrameStream` to read results frame by frame. * **`turnFromAiSdk`**: converts AI SDK `generateText` / `streamText` results into the standard event stream when you write your own HTTP web-Agent Adapter. For the full parameters, capability tables, and example code for these Adapters, see [Built-in Agent Capabilities Reference](/docs/reference/builtin-agents). ## How to choose * For a coding Agent that must change code and run commands on a real filesystem: `claude-code` / `codex` / `bub`. Choose configuration in the Sandbox Adapter sections above. * For an AI SDK application with a `useChat` backend: `uiMessageStreamAgent`, with zero mapping and HITL included. * For another deployed Agent system over HTTP / gRPC: use non-Sandbox integration and write an Adapter. The official SDK converters cover most mapping work. ## Related reading * [Connect your Agent](/docs/tutorials/connect-your-agent) — the full view: configuring an Experiment and writing an Eval. * [Sandbox Agent](/docs/tutorials/sandbox-agent) — running built-in Sandbox Agents and writing your own. * [Built-in Agent Capabilities Reference](/docs/reference/builtin-agents) — a capability-by-capability inventory of every Adapter and full example code for non-Sandbox Adapters. * [OTel integration](/docs/tutorials/connect-otel) — send spans to [NiceEval](https://niceeval.com/) to get the call waterfall in `niceeval view`. * [Adapter Reference](/docs/reference/define-agent) — complete parameters for `defineAgent` / `defineSandboxAgent`. # Keep Sandbox state to debug problems Source: https://niceeval.com/docs/troubleshooting/debug-sandbox Use --keep-sandbox to keep a failed Attempt's Sandbox as an environment you can wake at any time, enter it with niceeval sandbox enter to investigate by hand, and inspect and clean up with sandbox list / stop. By default, a Sandbox is destroyed after each Attempt ends. Most problems can be located first with `niceeval show @` or `niceeval view` by looking at the verdict, assertions, file changes, commands, and conversation. For the full workflow, see the [Debugging guide](/docs/troubleshooting/debugging). Some problems, though, can only be seen in a live environment: * **The environment does not start**: installing dependencies fails during setup, or the agent CLI cannot start. The agent has not started running yet, so the conversation record is empty. The fastest fix is to enter the Sandbox and rerun the install command by hand. * **The change is outside `git diff`**: which packages were installed globally, what configuration was written under `$HOME`, and what `PATH` actually is. The saved file changes do not include these. * **Rerunning is too slow**: a cold start plus installation takes minutes. When you want to test guesses one by one, keeping the environment is much faster than rerunning every time. ## Keep the state while running Add `--keep-sandbox` to `niceeval exp`: ```bash theme={null} pnpm exec niceeval exp local onboarding/tool-first --keep-sandbox # Same as --keep-sandbox=failed pnpm exec niceeval exp local onboarding/tool-first --keep-sandbox=all # Keep passing ones too ``` `--keep-sandbox` has two settings: * `failed` (the default) keeps Attempts whose verdict is `failed` or `errored`, including ones interrupted by a timeout. * `all` keeps passing ones too. Use it when tuning a setup Hook or checking the real state of the environment when it passes, so you do not have to make an eval case fail on purpose. Without this flag, everything is destroyed. A run with `--keep-sandbox` always really executes and never reuses earlier results. When the run ends, the summary lists which Sandboxes were kept and how to enter them: ```text theme={null} Kept sandboxes (1) @0123456789ABCDEFGHJKMNPQRS onboarding/tool-first #1 errored docker · a3f9c2d1 enter: niceeval sandbox enter a3f9c2d1 Stop them with: niceeval sandbox stop --all ``` Each line gives three things: the Attempt locator, the Sandbox instance id, and the command to enter the environment. To see the results saved for this Attempt, use `niceeval show @` or `niceeval view --run `. Kept Sandboxes do not keep running and consuming resources: Docker containers stop on disk, E2B microVMs pause billing, and Vercel saves the file system. ## Enter the environment ```bash theme={null} pnpm exec niceeval sandbox enter a3f9c2d1 ``` `niceeval sandbox enter` wakes the environment first, then opens a shell in the working directory. Inside is the environment as it was when this Attempt finished, so you can run commands, browse files, and reproduce the failure by hand. After you exit the shell, the environment goes back to sleep automatically. To keep it running, add `--leave-running`. ## Inspect and clean up The project keeps a record of kept Sandboxes. Manage them with `niceeval sandbox`: ```bash theme={null} pnpm exec niceeval sandbox list # List kept Sandboxes and their state pnpm exec niceeval sandbox enter a3f9c2d1 # Wake and enter; goes back to sleep on exit pnpm exec niceeval sandbox stop a3f9c2d1 # Destroy the given Sandbox (the id can be a unique prefix) pnpm exec niceeval sandbox stop --all # Destroy all ``` These management commands do not read `niceeval.config.ts`. Even when the configuration file cannot run for now, you can still inspect or destroy environments. The commands read `.env` to prepare Provider credentials; when `.env` cannot be read, fix it first and retry. The `sandbox` commands have no `--json` output. `niceeval sandbox list --json` reports `Unknown option '--json'` directly and does not go on to print human-readable text. `stop` can be run repeatedly. A Sandbox that is already gone (deleted by hand, or expired in the cloud) is not an error; the command just removes the record and says so. If the Provider fails to destroy it, the command keeps the record and returns an error so you can retry later, and it does not hide a resource that is still alive from the list. There is also a reminder if you forget to clean up: at the start of the next run, if Sandboxes from earlier are still kept, the terminal prints one line about it. ## Provider differences * **Docker**: the container stops on disk, uses no memory, survives a Docker restart, and starts automatically when you enter it. Containers never disappear on their own, so Docker is the only Provider that needs active cleanup. Besides `niceeval sandbox stop`, you can also check directly with `docker ps -a -f label=niceeval.keep-candidate=true`. * **E2B**: the microVM is paused, and files and memory are saved together. Billing stops while it is paused, it is kept indefinitely, and it resumes automatically when you enter it. * **Vercel Sandbox**: the microVM is stopped. The file system is saved and can be resumed later, but memory state is not kept, so processes must restart after waking. After the Provider's retention period passes, `niceeval sandbox list` marks it `expired`. * **Custom Providers**: Providers created with `defineSandbox` do not support keeping environments. The later `sandbox stop` does not load user configuration, so it cannot safely recover the destroy function you wrote in a new process. ## Boundaries A kept Sandbox is only for investigation. It cannot continue running, and it cannot be rescored. Verdicts, assertions, and file changes follow the saved results. Nothing you change in the environment is written back to the results. For how to view results, see [View run results](/docs/tutorials/viewing-results). Sandboxes left behind by a forced kill are not registered and do not appear in `sandbox list`. For how to reclaim them, see [Recover after a forced kill](/docs/troubleshooting/recover-after-kill#reclaim-sandboxes-left-by-a-forced-kill). # Debug failed eval results Source: https://niceeval.com/docs/troubleshooting/debugging When an eval fails or errors, start from a specific Run or Attempt and use show to drill into errors, conversations, timing, and changes, to find whether the problem is in the application, the Adapter, the environment, or the assertion. A batch of evals has finished, and a few are `failed` or `errored`. You need to find the cause: the application answered wrong, the Adapter mapping missed something, the environment did not start, or an assertion is too strict. Start from a specific Run or Attempt. Use `niceeval show` in the terminal and `niceeval view` in the browser; scripts and CI use `query` to read JSON. ## Find the problem Attempt from a Run First look at the run as a whole. The Run ID is in the `NEXT` panel when `exp` ends: ```sh theme={null} pnpm exec niceeval show --run ``` The output lists the Run's time, each eval case's verdict, scores, result coverage, usage, and limits. Each Attempt has a locator starting with `@` in front of it. Copy the one with the problem: ```sh theme={null} pnpm exec niceeval show @ ``` This screen first shows the verdict and an assertion summary. For `errored`, it shows the phase that failed and the full error text. Looking here first is usually enough. ## Choose what to look at by question When the summary is not enough, add one flag for the direction you suspect: ```sh theme={null} pnpm exec niceeval show @ --source pnpm exec niceeval show @ --execution pnpm exec niceeval show @ --timing pnpm exec niceeval show @ --usage pnpm exec niceeval show @ --diff ``` | You want to know | Flag | | - | - | | The eval source used in this run and each assertion's result | `--source` | | What the Agent said and which tools it called | `--execution` | | Which phase took the time, and whether it got stuck in setup | `--timing` | | How many tokens, requests, and how much cost this run used | `--usage` | | Which files the Agent changed | `--diff` | `--execution` shows a condensed execution outline. To expand one item, copy the ID next to it: ```sh theme={null} pnpm exec niceeval show @ --execution --expand ``` ## Find out why an assertion got this score When the problem comes down to one assertion, use `query` to get structured details. It returns that assertion's contributions, measurements, and the Judge's rationale: ```text theme={null} query discover → attempt.get(locator) → choose entryId from assertions.entries → attempt.assertion.detail(locator, entryId) ``` Do not work backward from the formatted text of `show` to assertion fields. For a complete request example, see [View run results](/docs/tutorials/viewing-results#see-why-an-assertion-got-its-score). ## Cannot open results | Error | Next step | | - | - | | The project's results file cannot be found | Confirm you are running in the project directory, and check that `.niceeval/record.sqlite` exists. | | The file named by `--record` is rejected | Confirm it is a complete single file copied after the run ended, and that no other process was writing during the copy. Do not delete WAL files that have not been merged. External files go through integrity and data consistency validation. | | Unsupported format version or invalid database | Keep the original file and check which NiceEval version wrote it. Supported historical formats are upgraded automatically; unknown formats or damaged data are not patched, and NiceEval does not read only part of them. | | A Run is still `active`, but its original process has ended | First check with `pnpm exec niceeval run show `, then run `pnpm exec niceeval run recover --yes`. | Published results cannot be edited by hand. When you need different results, run again to create a new Run. `niceeval run recover` only ends a Run whose original process is proven to have ended, and keeps every published Attempt. For the full steps, see [Recover after a forced kill](/docs/troubleshooting/recover-after-kill). ## Read historical Records The format version here is an internal identifier of the results file and is unrelated to NiceEval's npm package version. Do not try to upgrade by editing the version field in the database. `show`, `query`, `run list`, `run show`, and `view` can read historical results files in the 0.15, 0.16, and 0.17 formats directly. When reading, NiceEval upgrades them automatically in a private copy. The original file, Runs, Attempts, and references stay unchanged, and you do not need to run an eval first. When reading with `--record`, the file must be a complete single file that has already been handed over, and no other process can modify it while it is read. The command refuses to read when the file still has unmerged WAL, a run is still writing to it, or NiceEval cannot confirm that the writing process has ended. Do not delete the files next to it to get around the check. When you run write commands such as `exp` normally in a project, NiceEval first backs up and validates, then upgrades supported old formats automatically. Reading a project in the current format never runs an upgrade or recovery. ## Results are incomplete or cannot be compared Scripts or CI first run `pnpm --silent exec niceeval query discover` to find the right query, then use `explain` to check the request, and finally use `run` to execute it. The result shows which results are missing, what issues exist, which evidence was used, and how to fix it. `query` only outputs JSON and is not meant for people to read directly. To explain a score item by item, use `attempt.get` and `attempt.assertion.detail` instead of looking only at the summary score. When you need a browser interface, run `pnpm exec niceeval view --run --no-open`. Do not parse the View page, and do not query the tables in SQLite directly. To compare two Runs, use `runs.compare`. Its `exact` and `paired` modes refuse outright when evals or pairs cannot be aligned, instead of quietly shrinking the denominator. ## Read batched errors In a run, the first time a known kind of error appears, the terminal immediately shows the full cause, the responsible party, the source location, and the next step. When the run ends, a summary lists the total count, the affected objects, and the source locations. Migration guides are shown in full only once per `guideId`, not repeated for each object. Errors are grouped only when their `code`, `owner`, fix target, and `guideId` are all identical. The summary keeps at most 32 groups, and each group shows at most 32 affected objects and 32 source locations. Counts beyond that are stated separately, and every original error is still kept on its own. Unknown errors without a stable source are shown separately and are never merged just because their message or stack looks similar. Source locations come from known call sites or source maps, and show unknown when none is found. NiceEval does not scan the whole project to report errors, and does not require the application to enable extra AST scanning. ## Old project state blocks a run If the project data predates the formats that support automatic upgrade, or an error says old directories such as `locks/` or `sessions/` block writes, follow these steps to keep the history. The 0.15, 0.16, and 0.17 formats mentioned above are upgraded automatically and do not need this process. 1. Stop any NiceEval process still running in the original project. Do not decide whether a lock file can be deleted based on its modification time. 2. Keep the original project and the complete `.niceeval` directory. To view history, use the NiceEval version that wrote the data, in the original project. If you are not sure which version that is, keep everything as is and check first. 3. Copy the configuration, eval cases, experiments, and other project files to a new directory, without `.niceeval`, and install the current version of NiceEval in the new directory. 4. Run the original experiment in the new directory, for example `pnpm exec niceeval exp --rerun all`. New results go into the new directory's results file, and the original project's history stays as is. This process does not bring old results into the new results file. Do not delete individual lock files or data files to get around the check. `niceeval state migrate --all` manages user-level service state and cannot convert a project's results file. ## View shows old results When `niceeval view` opens, it reads the results published at that moment and stays on them. When the page says new results are available, confirm the refresh to see the latest content. If the refresh fails, the page keeps the last results it could read. For the normal workflow, see [View run results](/docs/tutorials/viewing-results). For command details, see [CLI](/docs/reference/cli). # Recover after a forced kill Source: https://niceeval.com/docs/troubleshooting/recover-after-kill After kill -9, a CI timeout, or a power loss interrupts the process, keep the results that already finished, end the Run that lost its process, and reclaim leftover Sandboxes. When `niceeval exp` is interrupted directly by `kill -9`, a CI time limit, or a power loss, the process has no chance to clean up. Attempts that already finished are still saved in the project's `.niceeval/record.sqlite` and can be read as usual; unfinished ones never leave half-written results. When you press Ctrl+C or send SIGTERM normally, NiceEval cleans up by itself: it saves the finished Attempts and marks the remaining planned Attempts as interrupted. This page only covers cases where the process had no chance to clean up. ## Look at the saved results first List the Runs this run created. If you got the `invocationId` from `--json` output or the CI log, use it to narrow the list: ```bash theme={null} pnpm exec niceeval run list --invocation pnpm exec niceeval run show pnpm exec niceeval show --run ``` You can also look in the browser: `pnpm exec niceeval view --run `. A Run that was force-killed is still `active`. It shows the Attempts that already finished, and the ones that did not run show as `pending`. Do not decide it can be taken over based on how long it has run, an expired PID, or a stopped heartbeat. ## End the Run that lost its process Run the recovery only after confirming the original process has ended: ```bash theme={null} pnpm exec niceeval run recover --yes ``` `run recover` first verifies that the original process really has ended. Once verified, it marks the Run `interrupted` and records why each unfinished Attempt is missing. It does not delete the Run or the finished Attempts, and the original process cannot write again even if it lingers. When the evidence is insufficient, the command refuses to recover. In that case, first confirm that the original process, the CI job, and any remote workers have all stopped, then retry. Do not bypass this check because "it has waited long enough". ## Rerun the unfinished part When you need complete results, run the original Experiment again. The new run creates a new Run; Attempts that finished last time and meet the reuse conditions are reused, and the rest run again. Before running, you can add `--dry` to see which will be reused and which will rerun. For the rules, see [Rerun only the evals affected by a change](/docs/tutorials/rerun-and-cache). ## Reclaim Sandboxes left by a forced kill Sandboxes that were running at the time of a forced kill are not registered, so `niceeval sandbox list` does not show them. First list these leftover instances read-only: ```bash theme={null} pnpm exec niceeval sandbox list --orphans ``` After confirming, reclaim them: ```bash theme={null} pnpm exec niceeval sandbox prune ``` * `orphan` means the process that created it on the same host is proven to have ended, so it can be destroyed safely. * `unverified` means it came from another host or cannot be confirmed, and it is not deleted by default. Use `pnpm exec niceeval sandbox prune --force` only after confirming nobody is using it. * Compose companion containers and networks are listed and destroyed as a group. * Vercel Sandbox leftovers cannot be looked up; they are reclaimed when the Provider's retention period ends. * Environments kept with `--keep-sandbox` are not affected by `prune`. Keep managing them with `niceeval sandbox stop`; see [Keep Sandbox state to debug problems](/docs/troubleshooting/debug-sandbox). ## Recover a shared-state lease For an Experiment that declares `sharedState.key`, only one run can hold the same key at a time. When the original run did not release it normally, new runs keep waiting until you recover it explicitly. First run the check command without a token to see the current holder's information: ```bash theme={null} pnpm exec niceeval exp --teardown \ --recover-shared-state ``` After confirming that the original run has ended, and that the remote services and checkpoints it operated on have stopped changing, copy the token the command shows and confirm explicitly: ```bash theme={null} pnpm exec niceeval exp --teardown \ --recover-shared-state \ --owner-token \ --confirm-owner-terminated \ --confirm-remote-quiesced ``` Recovery reruns `teardown` only for this Experiment. When the token is wrong, the token has expired, or teardown fails, the lease is not released, and later holders' leases are not affected. After fixing teardown, try again with the token currently shown. # Confirm Old Results Still Hold and Skip a Rerun Source: https://niceeval.com/docs/tutorials/accept-results When configuration changes and NiceEval will not reuse old results, but you have confirmed they are unaffected, accept them explicitly with niceeval accept and save a paid rerun. You moved your company's base image from an old registry to a new one. The image content did not change by a single byte; only the image address in the Experiment changed. NiceEval sees that the configuration changed, so it does not reuse the last results automatically, and `--dry` shows that the whole batch must run again. You know the change does not affect any task, but rerunning the whole batch costs real money. In this case, use `niceeval accept` to accept those old results explicitly. NiceEval does not let them through on your word alone. It verifies on its own that the change really does not affect the results. Only two kinds of change pass that check: * The Experiment was renamed and nothing else changed. You can also rename directly with `niceeval exp rename`; see the [CLI reference](/docs/reference/cli). * The image reference of the official `dockerSandbox` changed address, but both the old and new references spell out the same `@sha256:` digest and a complete platform. Tag-only images do not qualify, because the content a tag points to may have changed. Any change to the model, flags, Judge, task, assertions, or scores is rejected with `accept-ineligible`. Those changes can really change the result, so you should rerun. ## Find acceptable old results in the run plan First preview the Experiment you are about to run: ```sh theme={null} pnpm exec niceeval exp checkout --dry ``` Old results that cannot be reused automatically because of a configuration change show their full Attempt locator, along with an `accept` command you can copy directly. The locators you put in the command are the entire scope of the acceptance. NiceEval does not accept other results from the same Run or the same Experiment along the way. If all the old results come from the same Run and all of them can be accepted, `--dry` suggests `niceeval accept --run --dry` instead, so you can preview the whole Run at once. ## Check the old result before accepting it Open the old Attempt in the terminal with its full locator: ```sh theme={null} pnpm exec niceeval show @1K1P0VJAPVJ12 ``` Check the verdict, assertions, usage, duration, and evidence, and confirm this is the result you want to keep. When you need to see the whole Run it belonged to, open that Run: ```sh theme={null} pnpm exec niceeval show --run pnpm exec niceeval view --run ``` ## Accept the results you need ```sh theme={null} pnpm exec niceeval accept @1K1P0VJAPVJ12 ``` When the same Experiment has several results to accept, put all the locators in one command: ```sh theme={null} pnpm exec niceeval accept @1K1P0VJAPVJ12 @1K1P0VJAPVJ13 ``` To see what would happen without writing anything, add `--dry`. NiceEval checks every input first. If any locator is invalid, ineligible, or conflicts with another input, the command writes no results at all. It never accepts half of them. On success, NiceEval creates a new Run that references the original Attempts in the matching positions and records that you accepted them explicitly. The original Attempt, its Run, and its locator stay unchanged, and the result is not copied. ## Check the new Run The success output shows the new Run ID. Open it to confirm the results: ```sh theme={null} pnpm exec niceeval show --run ``` The denominator of this Run is still every Attempt the current Experiment plans to run. Positions you accepted show as `accepted`, and positions you did not accept show as `not-dispatched`. If you accepted only some of them, the report shows honestly that the results are incomplete. Each `accept` creates a new Run. It does not modify or merge an earlier acceptance. To get a batch of results into the same Run, put them in the same command. ## Common failures | Error | Next step | | - | - | | `malformed-locator` | Copy the full uppercase locator from the `--dry` or `show` output. Do not use an old UUID or a shortened prefix. | | `locator-not-found` | Make sure you run the command in the same project that produced the locator, and copy it exactly from the `--dry` or `show` output. | | `accept-ineligible` | Follow the hint to check the verdict, timeout, configuration change, or duration record. When you cannot prove the old result still holds, run it again. | | `duplicate-accept-member` | Keep only one locator per position and remove duplicate or conflicting inputs. | To choose between automatic reuse and a forced rerun, see [Rerun Only the Evals Affected by a Change](/docs/tutorials/rerun-and-cache). # Let a Coding Agent Iterate on Results Source: https://niceeval.com/docs/tutorials/agent-feedback-loop Let a Coding Agent run evals, read results, decide whether to change the application or the assertions, and verify again, without you copying terminal output back and forth. You ask a Coding Agent to fix a regression. It changes the code, you run the evals, you copy the failing output to it, it changes the code again, and you run again. After a few rounds, most of the time goes into moving terminal output around. A better approach is to let the Agent run the loop itself: 1. Run the evals. 2. Get this run's Run ID from the receipt printed when the run ends. 3. Use the Run ID to view the results and find the failing runs. 4. Decide whether to change the application, the Adapter, or the eval. 5. After the change, run the same scope again and confirm the fix. This page explains which command to use at each step and a few boundaries the Agent must respect. ## Have the Agent read the docs for the installed version Commands and APIs the Agent writes from training memory may not match the NiceEval version you installed. Have it read the docs that ship with the npm package first. NiceEval ships its Chinese docs in the npm package's `docs-site/zh/` directory and provides an `INDEX.md` at the package root for Coding Agents. The Agent reads `node_modules/niceeval/INDEX.md` first, then follows the index to the pages the current task needs. `pnpm exec niceeval init` initializes the configuration and writes a managed guidance section into the project's `AGENTS.md`. If the project only has `CLAUDE.md`, it writes into `CLAUDE.md`; if neither exists, it creates `AGENTS.md`. After upgrading NiceEval, run `init` again to refresh that section. The guidance separates two things: * When writing code, the Agent can of course read project source such as `evals/` and `agents/`. * When it needs to prove what happened in a run, it uses `niceeval show` in the terminal, `niceeval query` for stable JSON, and `niceeval view` for browser review. It does not scan raw files under `.niceeval/`, and it does not reconstruct a historical run from current source. When these commands cannot show the needed information, have the Agent report "the CLI cannot show this" instead of working around it by reading files. You can write the Agent's starting task like this: ```text theme={null} Read node_modules/niceeval/INDEX.md first. Then read the pages in the index that are relevant to the current task. Run the Experiment with the command, and pick the Run to inspect from the final receipt. ``` ## Run and save the receipt Have the Agent run with `--json` and save the output: ```sh theme={null} pnpm exec niceeval exp checkout --json | tee .niceeval-invocation.ndjson ``` Each line of `--json` describes the progress and diagnostics of this run. Exactly one final line is the receipt: ```json theme={null} { "type": "receipt", "receipt": { "invocationId": "01J8ZK3M6P4T7V9X2C5N8QW0RY", "createdRunIds": ["01J9ZK3M6P4T7V9X2C5N8QW0RY"], "publicationCutoff": "{...}", "startedAt": "2026-08-09T10:00:00.000Z", "completedAt": "2026-08-09T10:01:00.000Z", "completion": "completed" }, "summary": { "...": "..." } } ``` The Agent trusts only this last line. `createdRunIds` lists every Run created this time; use it to view results afterwards. Do not parse `publicationCutoff`; pass it to later commands as is. `completion` being `completed` only means the run ended. It does not mean the task is done, and the evals may not have passed. The receipt is also not a long-term result format; read results through `createdRunIds`. ## View results by Run ID After the run ends, the Agent first opens the Run from the receipt in the terminal: ```sh theme={null} pnpm exec niceeval show --run 01J9ZK3M6P4T7V9X2C5N8QW0RY ``` The output lists the Run's denominator, each eval's verdict, scores, result coverage, and the locator of each Attempt. To look at an Attempt in detail, copy its locator: ```sh theme={null} pnpm exec niceeval show @ pnpm exec niceeval show @ --source pnpm exec niceeval show @ --execution ``` `show @` shows the Attempt's verdict, assertion summary, whether each part is complete, and known limitations. `--source` shows the eval source used in this run and the result of each assertion. `--execution` shows a condensed execution trace; to expand one item, copy the ID next to it and run `--execution --expand `. When a script, CI, or Agent needs to process results reliably, first find the available queries, then send a complete request: ```sh theme={null} pnpm --silent exec niceeval query discover pnpm --silent exec niceeval query explain --request request.json pnpm --silent exec niceeval query run --request request.json ``` `query` returns JSON in a fixed format. When a person wants to look in the browser, use `pnpm exec niceeval view --run `; `view` does not accept locators. ## Decide the next step from the status | Status | Next step | | - | - | | Attempt `failed` | Compare the outcomes the task allows, the actual artifacts, the evidence, and the assertion conditions, then decide whether to change the system under test, the Adapter, or the eval. | | `errored` | Check the error text, Adapter, Sandbox, or credentials. | | `not-recorded` | An Attempt planned for this run has no result. Check why it did not run. | | `core-invalid` | Read the issue stated in the output. Do not treat it as missing collection. | | `partial` | The result is incomplete. Read the denominator and the issue notes to see which Attempts were not counted. | | `record-schema-unsupported` or old project state blocks the run | Keep the original project and its history, and follow the [old project recovery guide](/docs/troubleshooting/debugging#old-project-state-blocks-a-run) to rerun in a copy of the project. | | Query result is `unsupported` or `failed` | This only affects views that depend on that input. Read the stated issue. | When handling `failed`, do not change an artifact that already meets the task just to pass an overly narrow Match. If the artifact meets the task but the assertion rejects an outcome the task allows, the eval misjudged it and the eval should change. For the full attribution process, see the [debugging guide](/docs/troubleshooting/debugging). ## Run again after a change Have the Agent verify one hypothesis at a time: change the program or the eval, run the same scope, then read the new receipt and Run. By default, Attempts unaffected by the change that already have a conclusion from last time are reused. Add `--dry` before running to see which ones will be reused, which will rerun, and why. When every Attempt must really execute, add `--rerun all`: ```sh theme={null} pnpm exec niceeval exp checkout --rerun all --json ``` For the rules on reusing and accepting old results, see [Rerun Only the Evals Affected by a Change](/docs/tutorials/rerun-and-cache). A finished run only means this run handed off its results. It does not mean the user's task is done. * When the task asks for a fix and you have authorization, fix what made the run error first, then reverify only the affected scope. * When the task asks only for analysis, only do attribution. Do not change code or rerun. * When a business failure is what the task expects, keep the result and report it honestly. Do not change the application or the eval just to turn it green. ## Saved results cannot be changed Saved results are never modified. If the Agent wants a different result, it changes the program or the eval, runs again, and views the result through the new receipt's `createdRunIds`. When you need to hand results to someone else, copy the project's [`.niceeval/record.sqlite`](/docs/explanation/record). Do not use terminal output or NDJSON as a sharing format. # Onboard a Project from Scratch with a Coding Agent Source: https://niceeval.com/docs/tutorials/agent-onboarding The full onboarding flow for a Coding Agent: explore the project, confirm the path with the user, configure the Judge, write the Adapter, Experiment, and evals, and get the first experiment running. This page is the execution guide for a Coding Agent integrating NiceEval into a project. Prerequisites: the `niceeval` dependency is installed in the project, `niceeval init` has run, and you reached this page from the bundled `INDEX.md`. Talk to the user in the user's language. Every API, field, and CLI behavior follows the bundled docs. Do not make them up from training memory. Before writing evals, use [NiceEval Taste](/docs/tutorials/niceeval-taste) to decide the business criteria, sufficient evidence, and weights. At delivery, a reader should be able to explain the pass conditions and scoring by reading only the eval body. ## Step 1: Explore the project, then confirm with the user This step decides the whole path that follows. **Read the code and find out first, list your findings for the user to check, and only ask about what you could not find.** Do not open with a list of questions, and do not assume without exploring. Find out: 1. **What kind of Agent this is**: read the README, `package.json` dependencies, routes, and agent loop code to determine what it is built with (AI SDK, LangGraph, OpenAI Agents SDK, Claude Agent SDK, a custom loop...) and what its core use case is (customer support? SQL? coding tasks?). 2. **How the frontend talks to the Agent**: HTTP, or gRPC / WebSocket? Is the protocol standard or custom: a standard protocol like AI SDK UI Message Stream or OpenAI Responses / Chat Completions, a passthrough of the SDK's native event stream, or user-defined JSON/SSE frames? This directly decides whether the Adapter is built in (zero mapping) or a handwritten `send` (you write the event mapping). 3. **How the real target runs**: confirm the build or install command, start command, readiness check, port, dependent services, credential sources, and how to stop it. The Adapter only connects to the target. It does not start a substitute implementation inside `send` or the eval. Credentials go only to the run boundary that needs them, never into eval source, the workspace, or the run handoff. 4. **Whether the backend already uses OTel**: search for OTel SDK initialization, AI SDK telemetry, or instrumentation such as LangSmith / OpenLLMetry / OpenInference. If it exists, Tier 2 costs almost nothing. 5. **Whether the user already has A/B tests or feature flags**: if the application already has variant switches, the Experiment's `flags` can pass straight through to them (a ready-made Tier 3 entry point). 6. **What the Judge uses**: semantic evaluation (`t.judge(material, definition)`) needs **a Judge model separate from the Agent under test**, over an OpenAI-compatible protocol. Ask which service the user has a key for and which Judge model they want. A missing key never passes silently: Judge assertions are recorded as `unavailable`. Without Judge configuration, write exact Matches first. 7. **Whether the Agent itself must go into a Sandbox**: if the system under test is a coding agent CLI, or a Skill/Plugin/Hook/MCP server written for a coding agent (which edits files and runs commands in an isolated workspace), it must use a Sandbox. It cannot be called with `send` directly like an HTTP service. The default suggestion is `dockerSandbox({ source: { type: "image", image: "node:24-slim" } })`. If the task or existing configuration already specifies a Provider, keep it. Only ask when the Provider, Docker availability, or remote execution needs would still change the plan. The Provider is declared on the eval or the Experiment; there is no CLI flag, project-level default, or auto-detection. See [Choose a Sandbox Provider](/docs/tutorials/sandbox-providers). If the task has not specified an integration tier, **introduce the integration tiers** to the user and give a recommendation (see [Integration tiers](/docs/explanation/tier)): * **Tier 1 (send only)**: no change to the application. The full assertion set (text, Judge, multi-turn, tools, HITL) is available at this tier. * **Tier 2 (send + OTel)**: the application also sends a copy of its OTel spans to NiceEval, and you view the call waterfall in `niceeval view`. With existing instrumentation (found in point 3), it takes zero changes. * **Tier 3 (invasive changes + flags)**: expose internal application variants as `flags` for feature A/B testing. Existing A/B switches (found in point 4) are a ready-made entry point. **The default recommendation is to get Tier 1 running first, then move to Tier 2.** Especially when point 3 found existing OTel, tell the user clearly that "moving to Tier 2 just sends one more copy of the spans; the cost is close to zero." Bring up Tier 3 only when the user explicitly wants to compare variants. With explicit authorization, continue with the shape you found. Only confirm with the user when a missing decision would change the target, run boundary, credential boundary, or budget. Do not start writing the Adapter without reading the matching page: | System under test | Read | | - | - | | An application built on the Vercel AI SDK (a `useChat` backend) | [Built-in Agent reference](/docs/reference/builtin-agents): the built-in `uiMessageStreamAgent` connects without changes and without handwritten event mapping | | A coding agent CLI (claude-code / codex / bub, tasks that edit files) | [Evaluate an Agent in a Sandbox](/docs/tutorials/sandbox-agent): configure `sandbox`, default to `dockerSandbox({ source: { type: "image", image: "node:24-slim" } })`, and confirm the Provider with the user first | | A Skill, Plugin, Hook, or MCP server written for Claude Code / Codex | [Evaluate Coding Agent extensions](/docs/examples/coding-agent-extensions): also runs in a Sandbox; confirm the Provider the same way | | Other custom agent loops, LangGraph, OpenAI Agents SDK, deployed Agents | Start with [Connect Your Agent](/docs/tutorials/connect-your-agent); the full tutorial for a handwritten `send` is [Write send](/docs/tutorials/write-send) | | Pure functions with no separate service | First read the "why not call it directly" section of [Connect Your Agent](/docs/tutorials/connect-your-agent), confirm with the user that this edge case is really what they want, then continue | ## Step 2: Configure the Judge Configure the Judge service you asked about in step 1. In `niceeval.config.ts`, choose the protocol explicitly with a named Provider: ```ts theme={null} import { defineConfig } from "niceeval"; import { OpenAIProvider } from "niceeval/judge"; export default defineConfig({ judgeRuntime: OpenAIProvider({ model: "gpt-5.4-mini", // Required: there is no built-in default model // When using an OpenAI-compatible gateway, add these two explicitly: // baseUrl: "https://api.deepseek.com/v1", // apiKeyEnv: "DEEPSEEK_API_KEY", }), }); ``` **When connecting a compatible gateway, `baseUrl` must be set explicitly.** If you set only the key and not `baseUrl`, NiceEval calls the official endpoint, the gateway credential is sent to OpenAI, and you get back "Incorrect API key provided". It looks like an expired key, but the endpoint is wrong. Points to raise with the user: * **A misconfiguration never pretends to pass.** When the model or key cannot be read, or the request times out, the Judge assertion is recorded as `unavailable` with the reason. When the gateway rejects the request or returns the wrong shape, it is recorded as `errored`. Results never show a score that could not be obtained as a failure or a zero. * **Verify once after configuring.** Declare a rubric and anchors with `defineJudge`, then call `t.judge(material, definition).gate(0.8)`. After the run, check the score with `pnpm exec niceeval show --run `. See [Configure and Verify the Judge](/docs/tutorials/verify-judge) for the steps. * **Without a user present (autonomous onboarding), do not just skip the Judge.** First check whether the environment already has a key for the chosen Provider. If it does, verify it. If not, write exact Matches first and state at the end that the Judge is not configured. * The Judge model must be **separate from the Agent under test**, so a model does not grade its own output. Execution configuration is evaluated from the Experiment and the project config's `judgeRuntime`; for definitions and materials, see [Judge](/docs/explanation/judge). ## Step 3: Write the Adapter, Experiment, and evals After reading the docs for the direction chosen in step 1, write these in order: 1. **Adapter** (`agents/*.ts` or the directory the user's project uses): implement `send` with `defineAgent`. Configuration goes through factory parameters; do not hard-code it or read `process.env`. The contract is in [Adapter](/docs/explanation/adapter), API signatures in the [Adapter reference](/docs/reference/define-agent), and event mapping in the [events reference](/docs/reference/events). Two common pitfalls: **choose the endpoint or mode that enters the system's core capability, not the one that is easiest to get running.** For example, if the platform under test has both a "plain LLM chat" mode and a "connect to the database and execute" mode, connecting the former only evaluates an underlying model proxy, not the product itself. **Declare `evidenceCoverage` honestly according to what you actually map.** If you only map the final text, do not use `completeEvidenceCoverage`. The declaration affects assertion completeness, and over-claiming is worse than being conservative. 2. **Experiment** (`experiments/*.ts`): reference the Adapter above and declare `model`, `flags`, `attempts`, and so on. For a model comparison, write two experiment files, each with its own `model`. `evals: (eval) => boolean` decides which evals each Experiment runs. The file path decides the Experiment ID, and also which Experiments one command can run as a batch. 3. **Evals** (`evals/*.eval.ts`): **first find out what this application does, then write an eval that follows its real function.** Read its README, routes, tool definitions, or system prompt, find its core use case (ask a real support question of a support bot, give a SQL agent a real query task), and use that for the first eval's input and assertions. Two kinds of input do not qualify: placeholder input unrelated to the application, such as "hello", and **meta questions to the system about itself**, such as "what are you / what can you do". Those are not tasks users give it. Still start from the smallest form: one input, `t.succeeded()` plus one content assertion on the expected answer. But the minimal form is only scaffolding to get things wired up, not the delivery standard. Before finishing, it must also meet two conditions: * **Assertions must turn red when the system makes things up.** Do not assert on words already in the input (ask "what is X" and then assert the answer contains "X", and a system that repeats the question passes). Assert on substantive content unique to the expected answer: specific facts, structure (`hasSections()`), real links (`includesUrl()`), or a semantic verdict with `t.judge(material, definition)`. * **At least one negative case.** Give the system an input it should not be able to answer (a nonexistent table, a topic retrieval cannot find), and assert that it clearly says it cannot find or do it, instead of inventing a plausible result. For an agent connected to real data or a retrieval source, this is the failure mode most worth testing first. See [Write Evals](/docs/tutorials/authoring) for how to write them, [Eval Kinds and Assertions](/docs/tutorials/evaluation-kinds) for assertions and eval kinds, and the [defineEval reference](/docs/reference/define-eval) for signatures. For how parameters flow from the Experiment to the Adapter, and how to split static configuration from per-turn dynamic values (factory parameters vs `ctx`), see [Connect Your Agent](/docs/tutorials/connect-your-agent). Two architectural rules must not be broken when writing the Adapter: * **Do not make an in-process direct call.** Even if the agent runtime and the evals live in the same codebase, the Adapter must use HTTP (or the matching transport). Do not replace `fetch` with a direct `import` of the function under test. See "why not call it directly" in [Connect Your Agent](/docs/tutorials/connect-your-agent). * **The eval side does not manage the process under test.** Do not spawn the application or open another port. The user starts the application the way they normally do (`pnpm dev` and so on). When the Adapter cannot connect, report a clear error such as "start the application first". Do not start the service yourself. ## Step 4: Run and verify First use `--dry` to confirm which evals run and how many times each, then run for real. The examples use pnpm; if the user's project uses another package manager, switch to its `exec`: ```sh theme={null} pnpm exec niceeval exp models --dry # Confirm which evals each model configuration runs pnpm exec niceeval exp models # Run both model configurations by path pnpm exec niceeval show # View results in the terminal ``` When a person wants to compare results in the browser, run `pnpm exec niceeval view`. Follow the [Coding Agent Feedback Loop](/docs/tutorials/agent-feedback-loop) to run, view, change, and rerun. When a machine needs to read the results, first run `pnpm --silent exec niceeval query discover`, then send a complete request. For ways to view results, see [View Run Results](/docs/tutorials/viewing-results). Sort by final status first, then decide whether to change anything: * `errored` means the run itself did not complete and produced no result that can be scored. Check the target service, endpoint, credentials, dependencies, timeouts, and the Adapter's error mapping, fix them, and run again. * `failed` means the run completed but the business assertions were not met. First look at the assertions and evidence of the same Attempt. When the system under test really answered wrong, there is no need to turn it green. * After a scored eval completes, still look at the score and each assertion. A completed or passed overview does not mean a full score. Within existing authorization and budget, proactively fix what caused `errored`, and reverify only the affected scope. Read-only requests do not change or rerun anything. When real external conditions are missing, such as the target service, port, credentials, database, or model service, report the blocker clearly. Do not use a temporary stand-in to pretend the real target ran. To look at an Attempt's details in the terminal, first copy its locator from the current overview, then use the same locator to view each part: ```sh theme={null} pnpm exec niceeval show pnpm exec niceeval show @ pnpm exec niceeval show @ --execution pnpm exec niceeval show @ --source ``` Do not guess a locator from old output, and do not combine another run's source with this run's execution as evidence. Results can only prove what was recorded. `partial`, `unavailable`, and `not-recorded` mean the information is incomplete; they do not mean zero or failure. ## Step 5: Wrap up and tell the user what you did Before summarizing, go through the wrap-up self-check. **If any item is not met, go back to step 3 and fill it in. Do not gloss over it in the summary.** * [ ] The eval input is a core use case of the system under test, not a meta question about itself or a placeholder greeting * [ ] Every content assertion turns red when the system repeats the question or makes things up (the asserted words are not already in the input) * [ ] There is at least one negative case (an input it should not be able to answer, asserting that it clearly says it cannot) * [ ] With a key, the Judge is configured and a Judge score was seen in `niceeval show` or `niceeval view`. Without a key, the summary says so * [ ] The `model` / `flags` declared in the Experiment are actually consumed by the Adapter (no dead configuration nobody reads, and no invented model values the system does not have) * [ ] The real target was started the project's own way and confirmed ready; credentials went only to the run boundary that needs them and were not written into eval source, the workspace, or the handoff * [ ] At least one Attempt completed without being `errored`; business failures are kept honestly, and the same locator was used to check that scores, assertions, and evidence are complete After it runs, summarize first, then talk about next steps. The summary should cover: which system under test was connected, where the Adapter, Experiment, and evals live, how to run `niceeval exp `, `niceeval show`, and `niceeval view`, and what the first run's results look like. Do not refactor the user's existing code unasked, and do not add abstractions beyond these few files. ## Step 6: Ask the user whether to go deeper After summarizing, list the options for deeper integration. For each, state **what it does, roughly how much code changes, and what it buys**, and let the user choose. Do not do extra work on your own: | What it does | Change size | Benefit | Docs | | - | - | - | - | | Tool call assertions (`t.calledTool()` and so on) | Adapter only: map application responses to the standard event stream, about 10–30 lines of mapping code | Evals can assert "did the Agent call the right tool with the right arguments", not just the final reply | [Write send](/docs/tutorials/write-send), [events reference](/docs/reference/events) | | Multi-turn conversation, session isolation | Adapter only: wire up `ctx.session` (typed session state, or `id` + `capture()`), a few to a dozen lines | Evals can cover multi-turn scenarios and use `t.newSession()` to verify sessions do not leak into each other | [Drive multi-turn interaction](/docs/explanation/drive), [Write send](/docs/tutorials/write-send) | | Human approval flow (HITL) | Adapter only: while waiting for approval, have the current Turn return `waiting` and `input.requested`, then continue after the answer arrives, about 10–20 lines | Evals can cover approval scenarios such as "does the Agent behave correctly after approve/reject" | [HITL](/docs/explanation/hitl) | | Call waterfall (move to Tier 2) | If the application already has OTel instrumentation (checked in step 1): just send another copy of the spans to NiceEval, a few lines of configuration. Without instrumentation: add a generic OTel initialization | See the duration and token timeline of every model call and tool execution inside the application in `niceeval view`. Does not affect any assertion | [Configure OTel](/docs/tutorials/connect-otel) | | Feature A/B comparison (move to Tier 3) | Change the application: expose variants as configuration switchable through `flags`; size depends on the application. Existing A/B switches (checked in step 1) are a ready-made entry point | Compare "which prompt / tool set / feature switch is better" directly at the Experiment level | [Organize Experiments](/docs/tutorials/write-experiment), [Integration tiers](/docs/explanation/tier) | Also tell the user what these have in common: they all add to the Adapter or the application, and **the evals already written need no changes at all**. For what each tier buys and when an upgrade is worth it, see [Integration tiers](/docs/explanation/tier). If step 1 found existing OTel instrumentation in the application, proactively recommend the waterfall option, since its cost is close to zero. # Write Evals: Single-Turn, Multi-Turn, and Datasets Source: https://niceeval.com/docs/tutorials/authoring Write evals with defineEval: send messages, check replies and tool calls, run multi-turn conversations, generate many evals from one dataset, and prepare a workspace and clean up external resources for Coding Agents. The eval in the quickstart only checked whether the reply contained "30 days". A real Agent does more: it calls the right tools, remembers context across a multi-turn conversation, and edits code correctly. This page starts from the simplest eval and adds those checks step by step. ## Structure of an eval Evals live under `evals/`, with file names ending in `.eval.ts`, and default-export `defineEval(...)`: ```ts theme={null} // evals/billing/refund.eval.ts → ID is billing/refund import { defineEval } from "niceeval"; export default defineEval({ description: "Answers the refund policy correctly", tags: ["billing"], async test(t) { // Send a message, then check the result }, }); ``` * The file path decides the ID: the ID of `evals/billing/refund.eval.ts` is `billing/refund`. Only `.eval.ts` and `.eval.tsx` files are discovered. * `description` appears in results. Write it as one sentence about what this eval verifies. * `tags` are for selecting by tag, for example `niceeval exp local --tag billing`. The Experiment's `evals` function can read them too. * `test(t)` is the eval body. `t` has every method for sending messages, asserting, and working with the Sandbox. For other fields, such as `judge`, `timeoutMs`, `sandbox`, and `metadata`, see the [defineEval reference](/docs/reference/define-eval). ## Single-turn: send one message and check the result ```ts theme={null} import { defineEval } from "niceeval"; import { includes, jsonMatch, toolMatch } from "niceeval/expect"; export default defineEval({ description: "Calls the tool when asked about the weather", async test(t) { await t.send("What's the weather in Brooklyn today?"); t.succeeded(); t.calledTool(toolMatch("get_weather", { input: jsonMatch({ city: "Brooklyn" }) }).exactly(1)); t.check(t.reply, includes("sunny")); }, }); ``` This uses three kinds of checks: * `t.succeeded()`: this turn completed normally, without failing or stopping in a waiting state. * `t.calledTool(...)`: it called `get_weather`, the city in the arguments is Brooklyn, and it called it exactly once. Tool calls are only visible when the Adapter reports the matching events; see [Write send](/docs/tutorials/write-send#step-4-parse-tools-into-events). * `t.check(value, match)`: checks one specific value, here the reply text. A failed check does not stop the checks after it. When a run ends, you see every unmet condition at once, instead of fixing one and running again. When later code really depends on a result, use `await t.check(...).orStop()` to stop on failure. Add `.label("...")` to a check, and results show your description instead of a piece of code, which is easier to read: ```ts theme={null} t.calledTool("get_weather").label("Called the weather tool"); ``` `t.send()` returns this turn's `Turn`. Save it when you need to check one turn on its own: | Property | Content | | - | - | | `turn.message` | The assistant's text reply | | `turn.toolCalls` | This turn's tool calls | | `turn.data` | Structured output | | `turn.status` | `"completed"`, `"failed"`, or `"waiting"` | | `turn.usage` | Tokens and cost | | `turn.events` | The full standard event stream; every other property comes from it | ## Multi-turn: check that context carries over ```ts theme={null} export default defineEval({ description: "Drafts an email first, sends it after confirmation", async test(t) { const draft = await t.send("Draft a follow-up email for me."); draft.succeeded(); t.check(draft.message, includes("Best regards")); await t.send("Looks good, send it."); t.calledTool("send_email"); }, }); ``` Consecutive `send` calls on the same `t` form one conversation. `draft.succeeded()` only checks the first turn; `t.calledTool` checks whether the tool was called anywhere in the eval. When you need two unrelated conversations, for example to simulate two users, open a new session with `t.newSession()`. Multi-turn conversation requires the Adapter to carry the previous turn's context to the application; see [Write send](/docs/tutorials/write-send#step-2-carry-earlier-messages). ## Give open-ended answers to the Judge Questions like "is the answer grounded" or "is the tone right for elementary school students" cannot be written as a regex. Hand them to the Judge model to score: ```ts theme={null} import { defineEval, defineJudge } from "niceeval"; const grounded = defineJudge({ name: "grounded-answer", rubric: "Given the question, rate whether the answer is based on facts rather than invented or vague.", }); export default defineEval({ async test(t) { const turn = await t.send("What's the weather in Beijing today?"); t.judge({ question: turn.input, answer: turn.message }, grounded).gate(0.7).label("Answer is grounded"); }, }); ``` The Judge gives a score from 0 to 1, and `.gate(0.7)` means anything below 0.7 fails. When a deterministic rule can check something, such as which tool was called or whether a file changed, prefer the deterministic rule; save the Judge for truly open-ended quality. To configure which model the Judge uses, see [Configure and Verify the Judge](/docs/tutorials/verify-judge). ## Generate many evals from one dataset When dozens of evals share the same logic and differ only in input, you do not need dozens of files. Export an array from one file: ```ts theme={null} // evals/sql.eval.ts import { defineEval } from "niceeval"; import { equals } from "niceeval/expect"; const rows = [ { task: "Count orders", prompt: "How many orders are there?", expected: "42" }, { task: "Most expensive product", prompt: "What is the most expensive product?", expected: "MacBook Pro" }, ]; export default rows.map((row) => defineEval({ description: row.task, async test(t) { await t.send(row.prompt); t.check(t.reply, equals(row.expected)); }, }), ); ``` The generated IDs are `sql/0000` and `sql/0001`. To read a dataset from YAML or JSON and give each row a stable ID, see [Data-Driven Tests](/docs/tutorials/dataset-fanout). ## Prepare a workspace for a Coding Agent When evaluating a Coding Agent, the eval is still an ordinary `.eval.ts`. You just put a project into the Sandbox first, then let the Agent change it: ```ts theme={null} export default defineEval({ description: "Create a Button component", async test(t) { await t.sandbox.uploadDirectory("../workspaces/ts-starter"); const turn = await t.send("Create src/components/Button.tsx that accepts label and onClick props."); turn.succeeded(); t.sandbox.fileChanged("src/components/Button.tsx"); }, }); ``` For where the Sandbox comes from and how to score with the project's own tests, see [Fixture](/docs/tutorials/fixtures) and [Sandbox Agent](/docs/tutorials/sandbox-agent). ## Report progress and issues during a run When preparation is slow, use `t.progress` to show what is happening in the terminal. When something is worth recording but does not affect the conclusion, record a diagnostic with `t.diagnostic`: ```ts theme={null} export default defineEval({ async test(t) { t.progress({ message: "Uploading project", current: 1, total: 2 }); await t.sandbox.uploadDirectory("../fixtures/project"); const preflight = await inspectFixture(); if (preflight.usedFallback) { t.diagnostic({ code: "fixture-check-degraded", level: "warning", message: "Fixture preflight used the fallback checker", data: { checker: preflight.checker }, }); } await t.send("Complete the task"); }, }); ``` `progress` is only shown during the run and is not saved. `diagnostic` is saved with this Attempt and visible in the results afterwards, but it does not change pass or fail. Write business conclusions as assertions. When preparation fails and the run cannot continue, throw an exception directly, and this run is recorded as `errored`. ## Prepare and clean up external resources Some evals create temporary resources outside the Sandbox, for example a temporary repository on a shared Git service. Use `setup` to prepare and `teardown` to clean up; each runs once per Attempt: ```ts theme={null} // evals/pr-review/close-outdated.eval.ts import { defineEval } from "niceeval"; import type { Sandbox } from "niceeval/sandbox"; // Multiple Attempts of the same eval run concurrently and share this module, so store per sandbox instead of in a plain module variable const fixtures = new WeakMap }>(); export default defineEval({ async setup(sandbox, ctx) { ctx.progress({ message: "Creating temporary repository" }); const fixture = await createFixtureRepo("pr-review/close-outdated"); fixtures.set(sandbox, fixture); await sandbox.runCommand("git", ["clone", fixture.repoUrl, "workspace"]); }, async teardown(sandbox) { await fixtures.get(sandbox)?.destroy(); // May not exist if setup failed partway }, async test(t) { /* Have the Agent close outdated PRs, then assert */ }, }); ``` Key points: * Most evals do not need `teardown`. Files written into the Sandbox and installed dependencies disappear when the Sandbox is destroyed. Only resources outside the Sandbox need manual cleanup. * Once `setup` has started, `teardown` always runs, even if `setup` throws, so guard against resources that were never created. * When `teardown` throws or exceeds 30 seconds, it only records a `teardown-failed` diagnostic and does not change the conclusion this Attempt already reached. To make a check affect the conclusion, put it in `setup` or `test`. * When `attempts` is greater than 1, multiple Attempts of the same eval run concurrently. Store handles in a `WeakMap` keyed by `sandbox`, one per Attempt, so they do not overwrite each other. The full order of these steps in one Attempt: By default each Attempt has its own Sandbox, so "once per Sandbox" and "every Attempt" mean the same thing. They only diverge when reuse is enabled; see [Reuse Sandboxes](/docs/tutorials/sandbox-reuse). ## Next steps * When a task has partial completion and you want to score by how many steps were done: read [Pass/Fail and Scored Evals](/docs/tutorials/evaluation-kinds). * To write clear criteria that do not misjudge: read [NiceEval Taste](/docs/tutorials/niceeval-taste). * For every matcher: see the [expect reference](/docs/reference/expect). # Run Evals Automatically in CI to Block Regressions Source: https://niceeval.com/docs/tutorials/ci-integration Run the same niceeval exp on every PR and turn CI red when evals fail. Keep this run's Run ID for later steps to read, and hand a JUnit file to your CI platform for display. Someone changed a prompt, tried it locally twice, decided it was fine, and merged it. The next day you discover that another class of questions now gets every answer wrong. Put the evals in CI so every PR runs them automatically, and regressions are caught before merge. CI runs the same `niceeval exp` you use locally, with no extra configuration: * When evals fail, a run errors, or the run is interrupted, the process exits with a non-zero status and CI turns red. * With `--json`, the last line is this run's receipt, which contains the new Run IDs that later steps can use to read results. * With `--junit `, it writes a JUnit file so the CI platform can display results per test case. ## Decide what turns CI red Whether CI turns red depends only on the process exit status. Any of the following exits with a non-zero status: * A pass/fail eval has an Attempt that did not pass. * A scored eval has an explicit `.gate()` that was not met, for example a measured value below the minimum of `.gate(minimum)`. * An Attempt errored (`errored`), or not every planned Attempt got a result. * The run was interrupted, or the results could not be saved. A scored eval without a failed gate counts as success even when its score is low. To have a low score block the merge, add `.gate()` to the matching assertion in the eval. There is no global strict switch; CI and local runs read the same eval source and reach the same verdicts. ## Run the evals ```sh theme={null} pnpm exec niceeval exp ci --json --junit ./artifacts/niceeval-junit.xml ``` This command gives CI three things: * Exit status: CI uses it to decide whether this step succeeded or failed. * NDJSON output from `--json`: the earlier lines describe run progress and diagnostics, and the last line is the receipt. * JUnit file: for the CI platform to read and display. JUnit is only a display format for the CI platform. Verdicts, usage, assertions, and diagnostics are still saved in the project's `.niceeval/record.sqlite`, and later inspection reads from there. ## Configure GitHub Actions The step below saves the JSON output while passing the exit status of `niceeval exp` back to CI correctly: ```yaml theme={null} - name: Run evals shell: bash run: | set -o pipefail pnpm exec niceeval exp ci --json --junit ./artifacts/niceeval-junit.xml \ | tee ./artifacts/niceeval.ndjson exit "${PIPESTATUS[0]}" env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - name: Upload JUnit if: always() uses: actions/upload-artifact@v4 with: name: niceeval-junit path: ./artifacts/niceeval-junit.xml ``` Without `set -o pipefail` and `exit "${PIPESTATUS[0]}"`, the exit status comes from `tee`, and failing evals do not turn CI red. Inject model credentials as CI secrets into the environment variables the command needs, such as `ANTHROPIC_API_KEY` above. Do not write credentials into the Experiment, receipt, JUnit, or logs. For which variable each Agent reads, see [Configuration and Environment Variables](/docs/tutorials/configuration). ## Read this run's results in later steps Once the run starts, the last NDJSON line is the receipt. Its `receipt.createdRunIds` lists the Runs created this time, and later steps use it to open the results: ```sh theme={null} pnpm exec niceeval show --run 01J9ZK3M6P4T7V9X2C5N8QW0RY ``` To have a script process the results, first find the available queries, then send a complete request: ```sh theme={null} pnpm --silent exec niceeval query discover ``` If the command exits before the run starts because of an argument, configuration, or selection error, there is no receipt, and the terminal prints `error:`. Fix the command, Experiment, or selection directly. Do not guess a Run ID. ## Keep CI logs readable CI logs are for watching run progress and diagnostics. To dig into results, a person runs `niceeval show --run ` or `niceeval view --run ` locally with the Run ID from the receipt; scripts and AI run `query discover` first. Do not treat progress and diagnostic lines as a long-term result format. # Compare Two Runs: Is the Baseline or the Candidate Better Source: https://niceeval.com/docs/tutorials/compare-runs Pin the Run IDs of two runs, review them side by side in the browser, or use runs.compare to give scripts and CI a per-eval paired comparison. You ran a baseline on Monday, changed the prompt on Wednesday and ran again, and now want to know which evals this change made better and which it made worse. Before comparing, pin which run each side is. "The latest results" change with the next run; if you compare against them, today's conclusion and tomorrow's may differ. Every time `niceeval exp` finishes, the terminal's `NEXT` panel lists the Run IDs created this time. Write down the baseline and candidate IDs. ## View them side by side in the browser ```sh theme={null} pnpm exec niceeval view --run --run ``` View opens both Runs at once. You can compare pass/fail, scores, duration, and cost eval by eval, then click into a specific run to see the conversation and tool calls. ## Let scripts and CI read the comparison When an automated flow needs a structured comparison, use a `runs.compare` request: ```sh theme={null} pnpm --silent exec niceeval query run --request - <<'JSON' { "protocol": "niceeval.query/v1", "operation": { "kind": "runs.compare", "mode": "paired", "leftRunIds": [""], "rightRunIds": [""] } } JSON ``` `mode` decides how the two sides line up: | `mode` | When to use it | | - | - | | `side-by-side` | Each side gives its own results without computing differences. Use it when the two runs have different eval sets and you only want to view them side by side. | | `exact` | Requires both sides to have exactly the same eval set, and returns a named issue when they differ. Use it where comparisons must be on the same basis, such as release gates. | | `paired` | Pairs by the same eval and compares eval by eval. Most often used for "which evals did this change affect". | The comparison honestly reports each side's denominator, plus unpaired evals, excluded evals, and missing results. Missing results are not treated as zero or failure. When the conditions are not met, you get a named error explaining why, not a number that looks normal. First check that the request is valid with `query explain`, then run it for real: ```sh theme={null} pnpm --silent exec niceeval query explain --request compare.json ``` ## When a comparison can be trusted * Both sides ran the same set of evals, each run a similar number of times. * The two sides differ only in the one variable you want to compare; see [Organize Experiments](/docs/tutorials/write-experiment#compare-two-models) for how to write that. * Look at cost and duration alongside the pass rate. A slightly higher pass rate at double the cost is not necessarily worth switching to. For how to read a single run's results, see [View Run Results](/docs/tutorials/viewing-results). # Tune Concurrency: Throughput, Shared State, and a Fixed Execution Order Source: https://niceeval.com/docs/tutorials/concurrency When a batch of evals runs too slowly, first find where the time goes, then tune global concurrency and the Experiment's maxConcurrency. When Attempts need to share state, have them run serially in a fixed order. 200 evals took two hours, and you want them to go faster. The first instinct is to raise concurrency, but concurrency is only one possible cause. Unchanged tasks may be running again, every eval may be creating a new Sandbox, or the Provider's quota may hit its limit first. When you do decide to tune concurrency, remember that the number of Attempts running at once has two levels of limits: global concurrency controls how much this machine and the Provider can handle, and the Experiment's own `maxConcurrency` controls how many Attempts this Experiment runs in parallel at most. An Attempt starts only when both levels have a free slot. First find where the time goes, then change the matching setting: ## What each concurrency limit controls The number of Attempts that can run at once is decided by the following together. If any of them has no free slot, the Attempt queues: | Concurrency limit | Where it is set | What it controls | With several terminals | | - | - | - | - | | Global concurrency | `--max-concurrency`, or `maxConcurrency` in `niceeval.config.ts` | Total throughput of this run | Counted separately; values from two terminals add up | | Experiment concurrency cap | `maxConcurrency` in the Experiment file | How many Attempts this one Experiment runs at once | Counted separately; values from two terminals add up | | Docker profile capacity | Host profile configuration | How many containers and resources this profile allows at once | Shared by all terminals on the same host | | Shared-state lease | `sharedState.key` in the Experiment file | The whole window from restoring the same checkpoint, through execution, to writing it back | Runs with the same key queue | Global concurrency takes its value in this order: `--max-concurrency`, then `maxConcurrency` in the config, and finally the current Sandbox Provider's recommended value (`docker` 10, `e2b` 20, `vercel` 1). The recommended value reflects limits on the Provider side. When your own Agent interface is rate-limited, lower it with `--max-concurrency`: ```bash theme={null} pnpm exec niceeval exp compare --max-concurrency 19 ``` ## How slots are shared Below is a mixed batch: 3 global concurrency slots, `fast` is an ordinary Experiment, and `slow` declares `maxConcurrency: 1`. This diagram shows three things: * **`slow` always occupies one lane.** Its second Attempt waits until the first one's `teardown` and Sandbox destruction finish. Other Experiments in the batch are not affected and keep using the remaining slots. * **Slots go first to the Experiment that still has the most rounds to run.** Running fast and slow Experiments in one command is safe. You do not need to split them into batches by hand or reserve slots for fast tasks. Fast tasks fill free slots automatically. * **An Attempt backing off to retry gives up its global slot, but not its Experiment's own slot.** So when you hit rate limits, `running` in the live panel can exceed the limit, by exactly the number of Attempts backing off. At any moment, the work actually executing still does not exceed the limit. Concurrency control has not failed. Attempts waiting for Experiment-level `setup` (starting a tunnel, starting shared services) do not take a concurrency slot and show as `queued` in the counts. So when a tunnel is slow to start, the panel shows "0 running, N queued" for a long time; the problem is in `setup`, not in the concurrency configuration. Attempts waiting for Docker profile capacity also show as `queued`, with the queue reason `provider-capacity`. They do not show `creating sandbox` and do not take an ordinary concurrency slot. Once the profile has room, the Attempt gets a concurrency slot again and enters `running` only after it is assigned resources. Attempts in the same batch that use other Providers can keep starting and are not held up by Docker tasks ahead of them. ## Confirm in the live panel that concurrency took effect When a run starts, the `PLAN` panel tells you how many lanes this run opens and where that number came from. Experiments that declare `maxConcurrency` follow, each with its own cap: ```text theme={null} │ 45 attempts · 9 evals × 5 configs · concurrency 19 (from flag) · slow ≤1 │ ``` `(from flag)` means 19 came from `--max-concurrency`. When no flag was passed and the config does not set one, this shows a Provider recommendation such as `(from vercel default)`. Opening only 1 lane is not a scheduling problem; the `vercel` recommendation is 1. `slow ≤1` means this Experiment is bounded by its own cap, and raising `--max-concurrency` will not make it open more. During the run, watch the top-line counts: ```text theme={null} │ 45 total · 6 reused · 19 running · 12 queued · 6 passed · 2 failed · 0 errored · 0 skipped │ ``` * `running` stays at the limit while `queued` drops steadily: concurrency is the bottleneck. If the machine still has capacity, raise it. * `running` stays below the limit for a long time: the bottleneck is elsewhere. It may be the Experiment's own cap, Provider capacity, or waiting for Experiment-level `setup`. Attempts waiting for capacity show `waiting for provider capacity` on their activity line. ## Run serially when Attempts share state When several evals all need to read, modify, and write back the same host file or server-side state, reduce this Experiment to one lane: ```ts theme={null} export default defineExperiment({ agent: codexAgent(), sandboxReuse: true, maxConcurrency: 1, // One after another within this run sharedState: { key: "mempal/codex/cohort-a" }, // ... }); ``` `maxConcurrency: 1` only makes the Attempts within this one `niceeval exp` command serial. When another terminal runs at the same time, `sharedState.key` ensures the two sides never modify the same checkpoint at once: * The lease is acquired before the Experiment's and Sandbox's `.before()`, and released only after Sandbox cleanup, Provider finalization, and Experiment `teardown` all finish. * The waiting side does not create a Sandbox early. After it gets the lease, it continues with its own plan and does not read or reuse the other side's results. `sharedState` only prevents simultaneous modification. It does not save the checkpoint, and it does not handle atomic commit, rollback, or recovery from a half-finished write after a forced kill. Write back atomically when you can. When you cannot, switch to a new key and rebuild from a clean state. Coordination across machines still needs external orchestration. NiceEval never hands a lease to someone else because of a timeout, process ID, or heartbeat. A paused holder still holds the lease. After the holder is force-killed or fails to clean up, the waiting side keeps waiting, so the two sides never modify the same state at once. After you confirm that the holder has exited and the external state no longer changes, use the commands in [Recover After a Forced Kill](/docs/troubleshooting/recover-after-kill#recover-a-shared-state-lease) to inspect the holder and recover manually. When only one Agent service is often rate-limited, also set `maxConcurrency: N` on that Experiment instead of lowering the global limit, which would slow down the other Experiments in the batch. During backoff, the Experiment-level cap does not let an N+1th Attempt in, so a rate-limited service is not put under more pressure. ### Store Hook state by Sandbox Under concurrency, the same module serves several Sandboxes at once. A handle obtained in `setup` cannot be stored in an ordinary module variable, or the next concurrent Attempt overwrites it. Store and look it up keyed by the Sandbox instance: ```ts theme={null} const fixtures = new WeakMap }>(); ``` If these Attempts really must read and write the same business state in order, do not hide that with a `WeakMap`. Reduce the Experiment to `maxConcurrency: 1` directly. ## Let related evals reuse a Sandbox by group When only a few groups of evals need a shared environment, list the members of each group with `defineEvalGroup()`. Each group runs only one Attempt at a time and reuses at most one Sandbox. Other groups and ungrouped evals keep using the remaining concurrency slots. You do not need to set the whole Experiment to `maxConcurrency: 1`. The `evals` array only lists members; it does not decide the order. Within a group, evals run sorted by eval ID. When `attempts > 1`, the Attempts of one eval run back to back before the next eval's turn. For the full directory layout, Experiment shape, and `--dry` check, see [Reuse Sandboxes with Eval Groups](/docs/tutorials/eval-groups). An eval group is not a task dependency graph. Reusing last results and command-line filtering can leave an earlier eval unexecuted, and the reset between tasks also undoes an earlier eval's changes to `workdir`. When a later step must read a file from an earlier step, put them in the same eval. Do not depend on the order within a group. ### Ordered output does not require serial runs Results are always output in discovery order, regardless of how much concurrency this run used. Row order in the terminal and in reports is therefore stable, and you can diff them directly. If ordered output is all you want, you are done here: naming prefixes still work, and you do not have to give up throughput. ## Make early exit actually save money `earlyExit` stops Attempts that have not been dispatched yet. Under default concurrency, several Attempts of the same eval may already be running at the same time. When the first one passes, the later ones are already running, so no money is saved. To get "run once, stop if it passes, run the next only if it did not": ```ts theme={null} export default defineExperiment({ attempts: 5, earlyExit: true, maxConcurrency: 1, // With only one slot, early exit takes effect before the next one is dispatched // ... }); ``` ## Share the result file across terminals Two terminals can run in the same project at the same time, and both write results into `.niceeval/record.sqlite`. But they do not divide the work automatically. After confirming that the machine, Provider, and Agent service have spare capacity, run directly in both terminals: ```bash theme={null} pnpm exec niceeval exp compare --max-concurrency 2 pnpm exec niceeval exp compare --max-concurrency 2 ``` Each side plans the complete selection on its own and may execute the same evals twice, because neither can see the other's unfinished Attempts. An Attempt can be viewed as soon as it completes, even while its Run is still running. When you need to keep results apart, run in another project directory; NiceEval does not merge results from two projects. When running several terminals: * The `--max-concurrency` values of both sides add up; Provider capacity does not. When quota is tight, lower both. * The Experiment's own `maxConcurrency` is also counted per terminal: when both commands declare 3, up to 6 run in total. * The two terminals' Sandboxes are independent as well. Declare `sharedState.key` only when you really share an external checkpoint; it has nothing to do with the result file. * Do not copy `.niceeval/record.sqlite` while runs are in progress. Copy or archive the file after the command finishes successfully. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. ## Boundaries * Concurrency limits control resource use, not spending. To limit spending, use `--budget`; see [Control Run Cost](/docs/tutorials/control-run-cost). * Some Providers declare that they can only run one at a time, and no `--max-concurrency` value changes that. It is a correctness requirement, not a scheduling parameter. * When an Agent service bills or limits by "number of tasks running at once", setting the global limit right at the service limit does not hold steady: a slot given up during backoff is immediately filled by a new Attempt, so concurrency on the service side stays at the limit. In a single terminal, leave headroom with an Experiment-level `maxConcurrency`. With several terminals, the values add up, so lower each side separately for an account-level quota, or hand it to external orchestration. * An Experiment that declares `sandboxReuse: true` runs one after another inside each Sandbox, and Sandboxes run in parallel; see [Reuse Sandboxes](/docs/tutorials/sandbox-reuse). * An eval group runs in order internally, and different groups can still run in parallel; see [Eval Groups](/docs/tutorials/eval-groups). ## Read next Which results do not cost money again this time. Another path to speed: pay for shared preparation only once. Reuse a Sandbox serially and stably within a group while other groups keep running in parallel. Where `maxConcurrency` and Experiment-level lifecycle go. The full mechanism of discovery, dispatch, retries, and budgets. # Put Configuration and Secrets Where They Belong Source: https://niceeval.com/docs/tutorials/configuration Which layer of code holds values such as attempts, timeouts, concurrency, and the Judge, and which environment variables hold API keys and provider tokens. You want CI to run each eval 5 times and local runs only once, without committing an API key to the repository. NiceEval splits values into two kinds, each in its own place: * **Configuration goes in code**: CLI flags, Experiment files under `experiments/`, and `niceeval.config.ts` at the root. Attempts, timeouts, concurrency, and which model the Judge uses all live here, and none of them have a matching environment variable. * **Secrets go in environment variables**: API keys and Provider tokens, plus settings that describe the current terminal, such as `NO_COLOR`. So each value has exactly one source. What `niceeval exp --dry` prints is the value that really takes effect; no environment variable changes it behind your back. CLI and runtime text is in English; `niceeval view` in the browser can switch between Chinese and English. ## Configuration: choose the layer by how long the value should live First decide how widely the value should apply, then decide which layer to write it in. When the same value appears in several layers, it is taken in the order **CLI flag → Experiment → eval → config → built-in default**; once an earlier layer has a value, later ones are not consulted. The config only supplies defaults and never overrides other layers: a `timeoutMs` declared by an eval itself is not replaced by the project default. **Different only this once**: write it on the command. ```bash theme={null} pnpm exec niceeval exp ci --attempts 5 --timeout 600000 ``` **Always this way for this experiment**: write it in the Experiment file. ```ts theme={null} // experiments/ci.ts import { defineExperiment } from "niceeval"; import { codexAgent } from "niceeval/agents"; export default defineExperiment({ agent: codexAgent(), model: "gpt-5.4", attempts: 3, timeoutMs: 600_000, budget: 5, }); ``` `agent`, `model`, and `flags` can only be written here; there is no matching CLI flag. To switch Agent or model, copy an Experiment file. That way, which Agent and model each run used is recorded in the results and can be reproduced later. **Shared across the whole project**: write it in `niceeval.config.ts`. ```ts theme={null} import { defineConfig } from "niceeval"; import { OpenAIProvider } from "niceeval/judge"; export default defineConfig({ judgeRuntime: OpenAIProvider({ model: "gpt-5.4-mini" }), maxConcurrency: 4, timeoutMs: 300_000, }); ``` ### What `niceeval.config.ts` can hold | Field | What goes in it | | - | - | | `name` | Project name, shown at the top of `niceeval view`. Can have one per language | | `judgeRuntime` | Default Judge Provider, built with a named factory from `niceeval/judge` | | `workspace` | Root of the workspace uploaded into the Sandbox | | `reporters` | Default reporter list (write to disk, report to an experiment platform) | | `maxConcurrency` | Default concurrency cap | | `timeoutMs` | Default timeout per attempt | | `telemetry` | OTLP receiver configuration (`host` / `port`); OTel integration is configured only here | | `pricing` | Price table overrides, by model name or `provider/*` wildcard | Each field's type and full description are in the [defineConfig reference](/docs/reference/define-config). The full flag table is in the [CLI reference](/docs/reference/cli). ## Environment variables: only secrets and the terminal Put secrets in the environment variables below. These are all the environment variables NiceEval reads; each Agent, Sandbox, and Judge only recognizes its own few names and never reads other keys. | Variable | Used by | Notes | | - | - | - | | `ANTHROPIC_API_KEY` | `claudeCodeAgent()` | Can be overridden by the factory parameter `apiKey` | | `ANTHROPIC_BASE_URL` | `claudeCodeAgent()` | Gateway address; can be overridden by the factory parameter `baseUrl` | | `CODEX_API_KEY` / `CODEX_BASE_URL` | `codexAgent()` | Key required, proxy address optional; can be overridden by the factory parameters `apiKey` / `baseUrl` | | `BUB_API_KEY` / `BUB_API_BASE` | `bubAgent()` | Both required; can be overridden by the factory parameters `apiKey` / `apiBase` | | `OPENCLAW_API_KEY` / `OPENCLAW_BASE_URL` | `openClawAgent()` | When the key is omitted, falls back to `ANTHROPIC_API_KEY` | | `OPENCODE_API_KEY` / `OPENCODE_BASE_URL` | `openCodeAgent()` | When the key is omitted, falls back to `ANTHROPIC_API_KEY` | | `HERMES_API_KEY` / `HERMES_API_BASE` | `hermesAgent()` | When the key is omitted, falls back to `OPENROUTER_API_KEY`, then `ANTHROPIC_API_KEY` | | `OPENAI_API_KEY` | `OpenAIProvider` | Default key for OpenAI or an explicit compatible gateway | | `AI_GATEWAY_API_KEY` | `VercelProvider` | Vercel AI Gateway key | | `OPENROUTER_API_KEY` | `OpenRouterProvider` | OpenRouter key | | `TYPESAFE_API_KEY` | `TypesafeProvider` | TypeSafe System One key | | `E2B_API_KEY` | `e2bSandbox()` | | | `VERCEL_API_TOKEN` | `vercelSandbox()` | | | `VERCEL_TEAM_ID` | `vercelSandbox()` | | | `VERCEL_PROJECT_ID` | `vercelSandbox()` | | | `NO_COLOR` | CLI output | When set, no colors or box lines are drawn | To have the Judge read its key from a different variable name, spell out the variable name in the config: ```ts theme={null} import { OpenAIProvider } from "niceeval/judge"; export default defineConfig({ judgeRuntime: OpenAIProvider({ model: "gpt-5.4-mini", baseUrl: "https://gateway.example.com/v1", // The gateway address is configuration, so it goes here apiKeyEnv: "MY_GATEWAY_KEY", // The key is a secret, so read it from this environment variable }), }); ``` When the gateway address should not be committed to the repository either, read an environment variable yourself in the config. The config is code, and `.env` has already loaded by the time it is read: ```ts theme={null} export default defineConfig({ judgeRuntime: OpenAIProvider({ model: "gpt-5.4-mini", baseUrl: process.env.MY_GATEWAY_URL, // You name the variable and read it yourself apiKeyEnv: "MY_GATEWAY_KEY", }), }); ``` The only difference: the variable name is decided by your project, not a name built into NiceEval that it guesses from the environment. For local development, put secrets in a `.env` file in the current directory. The CLI loads it automatically at startup without overriding environment variables that already exist, so you do not need to `export` every time: ```bash theme={null} # .env ANTHROPIC_API_KEY=sk-ant-... OPENAI_API_KEY=sk-... ``` `.env` is only for secrets; it is not a second configuration file. Writing variables like `NICEEVAL_TIMEOUT` into it has no effect. CI likewise passes only secrets: ```yaml theme={null} - run: pnpm exec niceeval exp ci --junit .niceeval/junit.xml env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ``` ## Which layer to write in when tuning these values Configuration has no environment variable layer, so each item below can only be written in these places: | What to tune | Where to write it | | - | - | | How many times each eval runs | `--attempts`, or the experiment's `attempts` | | Timeout per Attempt | `--timeout`, or the experiment / config `timeoutMs` | | Spending cap | `--budget`, or the experiment's `budget` | | Concurrency cap | `--max-concurrency`, or the config's `maxConcurrency` | | Judge model and endpoint | A named Provider in the config or Experiment; an eval can switch only the model with a `judge` string | | OTel receiver address | The config's `telemetry: { host, port }` | Each Judge Provider reads only its own default key variable, or the variable named explicitly by the factory's `apiKeyEnv`. It never borrows keys from other Providers or from the application under test. ## Read next Type and full description of each configuration field. Commands, full flag table, and exit codes. Which values belong to one specific run. Pass secrets in CI. # Connect OTel: See a Per-Turn Call Waterfall in the Browser Source: https://niceeval.com/docs/tutorials/connect-otel When your application already sends OTel traces, have it send a copy of its spans to NiceEval as well, and see each turn's model calls, tool executions, and durations in niceeval view. Assertions are unaffected. An eval took 40 seconds, and you want to know where the time went: was the model slow to think, or did a tool call get stuck? The terminal only tells you how long the whole turn took. A call waterfall answers that. In `niceeval view` it lists every model call and tool execution turn by turn, with each one's duration and tokens. If your application already sends OTel traces, you already have this data. AI SDK telemetry, LangGraph traces exported to LangSmith, OpenLLMetry or OpenInference auto-instrumentation, and your own instrumentation following GenAI semantics all count. Have the application send a copy of its spans to NiceEval too. No business code in the application changes, so this is still a non-invasive integration; see [Tier](/docs/explanation/tier). The waterfall is only for viewing and does not take part in scoring. Assertions such as `t.calledTool` and `t.maxTokens` still read the events and usage the Adapter returns from `send`. Missing or late spans only make the waterfall incomplete; they never change pass or fail. ## Where spans come from and where they go When evals run, [NiceEval](https://niceeval.com/) starts an OTLP receiver on the local machine. Spans sent by the application are grouped by `send` turn, normalized to GenAI semantics, and saved with the Attempt's results; `niceeval view` then draws the waterfall from them. **Spans do not take part in assertions** and do not affect the Agent's behavior, execution errors, or verdict. Missing instrumentation, late spans, or a lost batch only make the waterfall incomplete. ## Send spans to NiceEval The integration has three changes: the Adapter carries each turn's trace header, the application knows where to send, and the application's instrumentation turns on export. **1. The Adapter carries `traceparent`.** Write `send` as usual with the same event mapping, plus one line: send this turn's `traceparent` to the application with the request. ```ts theme={null} // agents/support-bot.ts import { completeEvidenceCoverage, defineAgent } from "niceeval/adapter"; export default defineAgent({ name: "support-bot", evidenceCoverage: completeEvidenceCoverage, async send(input, ctx) { // Example address: replace with your own agent's real endpoint const r = await fetch("http://localhost:5188/chat", { method: "POST", // Carry traceparent with the request: this turn's spans attach to NiceEval's trace, so attribution stays exact under concurrency headers: { "content-type": "application/json", ...ctx.telemetry?.headers }, body: JSON.stringify({ message: input.text, model: ctx.model }), signal: ctx.signal, }); const body = await r.json(); return { status: r.ok ? "completed" : "failed", events: mapToEvents(body), // Assertions are based on this, the same as without OTel }; }, }); ``` With `uiMessageStreamAgent` you do not need this line; it adds `ctx.telemetry.headers` to the request headers automatically. **2. Tell the application where to send.** The receiver address is configured when the application starts and **does not go through `send`**. A standard OTel SDK reads the `OTEL_*` environment variables only once, at process startup. Choose by how the application runs: * **A service you start yourself that keeps running (most common)**: fix the receiver port in `niceeval.config.ts`. Writing this configuration turns on OTel integration: ```ts theme={null} // niceeval.config.ts export default defineConfig({ telemetry: { port: 4318 }, // The receiver always listens on http://localhost:4318/v1/traces }); ``` Set `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces` once when the service starts, and you never need to change it no matter how many evals you run. When forwarding through an OTel Collector, point it at this address too. Because the port is fixed, only one niceeval process can run on the same machine at a time. When another process holds the port, [NiceEval](https://niceeval.com/) reports an error directly and tells you to pick a free port. The receiver address given to the application defaults to `127.0.0.1`. Only when the application has to go through a tunnel or other routing to reach the machine running NiceEval, use `host` to write that address, for example `telemetry: { host: "otel.internal", port: 4318 }`. A Docker Sandbox Agent starts the receiver inside the same Sandbox by default, so the container does not need to connect back to the host. OTLP receiving is configured only through these two fields and does not read environment variables. * **A child process started by [NiceEval](https://niceeval.com/)** (CLI-style Agents): nothing to do. Put `ctx.telemetry.env` (standard `OTEL_*` environment variables, ready to spread) into the process environment. Each run is a new process, so it reads this run's address. **3. Turn on export in the application's instrumentation.** Add a few lines of configuration for the instrumentation library you use: The official OTel integration is recommended (`@ai-sdk/otel`, which produces standard GenAI semantics). The older `experimental_telemetry` (`ai.*`) can also be drawn: ```ts theme={null} import { generateText } from "ai"; const result = await generateText({ model, tools, messages, experimental_telemetry: { isEnabled: true }, }); ``` The exporter uses the standard OTel Node SDK, with the endpoint pointing to [NiceEval](https://niceeval.com/) (injected env or fixed port). Runnable examples: application-side instrumentation in [`examples/zh/origin/ai-sdk-v7`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/ai-sdk-v7) (`src/backend/otel.ts`, official `@ai-sdk/otel`), and the full eval project after integration in [`examples/zh/tier1/ai-sdk-v7`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/ai-sdk-v7). A zero-dependency route with three environment variables: ```bash theme={null} LANGSMITH_TRACING=true \ LANGSMITH_OTEL_ENABLED=true \ LANGSMITH_OTEL_ONLY=true \ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces \ node server.js # Choose the endpoint value as described in "Tell the application where to send": injected env or fixed port ``` `LANGSMITH_OTEL_ONLY` means send OTLP only and nothing to the LangSmith cloud. Remove it when you need to send to both. The **Python** `langsmith` SDK registers the OTel hook automatically at import time. The **JS** version (`langsmith@0.7.x`) also needs an explicit call to `initializeOTEL()` exported from `langsmith/experimental/otel/setup`; without it, it only prints a warning and produces no spans. The three environment variables stay the same. Python backend example: [`examples/zh/origin/langgraph`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/origin/langgraph); full eval project: [`examples/zh/tier1/langgraph`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1/langgraph). ```ts theme={null} import * as traceloop from "@traceloop/node-server-sdk"; traceloop.initialize({ disableBatch: true }); // The endpoint uses the standard OTEL_EXPORTER_OTLP_ENDPOINT environment variable ``` ```python theme={null} from openinference.instrumentation.langchain import LangChainInstrumentor from phoenix.otel import register register() # Or the standard OTel SDK; the endpoint uses OTEL_EXPORTER_OTLP_ENDPOINT LangChainInstrumentor().instrument() ``` Instrument according to the [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/): model call spans named `chat {model}`, tool spans named `execute_tool {tool}`, with attributes `gen_ai.operation.name` and `gen_ai.tool.name` / `gen_ai.tool.call.id`. These are the semantics [NiceEval](https://niceeval.com/) normalizes to, so the waterfall comes out most complete. ## Attribute spans to Turns When several evals run in parallel, the same receiver gets spans from several conversations at once. [NiceEval](https://niceeval.com/) sorts them into their turns in two ways: * **By `traceparent` (recommended, accurate under concurrency)**: when `send` makes the request, spread `ctx.telemetry.headers` into the request headers so each turn carries a new `traceparent`. When the application's instrumentation supports trace context propagation (all standard OTel HTTP server instrumentation does), this turn's spans attach under the trace [NiceEval](https://niceeval.com/) provided and match exactly by traceId. * **By time (fallback)**: when the application does not propagate trace context, attribution uses the start and end times of `send`. This is only reliable when turns run one at a time, so [NiceEval](https://niceeval.com/) runs this Agent's turns one at a time and notes it in the logs, instead of mixing spans from different conversations. Once `traceparent` is confirmed to work, concurrency resumes automatically. The application must export spans promptly. Use `SimpleSpanProcessor`, or flush at the end of each turn. `BatchSpanProcessor` buffers for a while before sending, so spans can arrive late into the next turn; a waterfall that occasionally misses its tail is usually caused by this. ## Keep your existing OTel backend and send to both The application most likely already sends traces to its own observability backend, such as Langfuse, SigNoz, or a production collector. Connecting [NiceEval](https://niceeval.com/) **does not require switching backends or a second set of instrumentation**. A TracerProvider can have several SpanProcessors, sending the same spans to two places: ```ts theme={null} // instrumentation.ts — initialize once at application startup: one set of instrumentation, two outputs import { NodeTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; export const provider = new NodeTracerProvider({ spanProcessors: [ // Output 1: your own backend, always on new SimpleSpanProcessor(new OTLPTraceExporter({ url: process.env.MY_COLLECTOR_URL })), // Output 2: NiceEval. In production this variable is absent, so this output does not exist; same code online and offline ...(process.env.OTEL_EXPORTER_OTLP_ENDPOINT ? [new SimpleSpanProcessor(new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }))] : []), ], }); provider.register(); ``` When changing application code is inconvenient, forward through an OTel Collector: the application sends only to the collector, and the collector has two exporters, one pointing to your backend and one to [NiceEval](https://niceeval.com/)'s fixed address. The cost is one more component to maintain. ## Control waterfall content with semantic mapping Whether a span shows in the waterfall as a model call, a tool execution, or a sub-Agent depends on its attributes. Before drawing, [NiceEval](https://niceeval.com/) normalizes each span to GenAI semantics, mainly looking at `gen_ai.operation.name` (the standard operation name) and the normalized `kind`: | Span semantics | Waterfall content | | - | - | | `gen_ai.operation.name: "chat"` (or `text_completion` / `embeddings`) | Colored and categorized as a **model call** | | `gen_ai.operation.name: "execute_tool"`, or a `tool_name` attribute | Colored and categorized as a **tool execution** | | `gen_ai.operation.name: "invoke_agent"` / `"create_agent"` | Colored and categorized as a **sub-Agent** | | Attribute `call_id` equals the `operationId` in an event returned by `send` | This tool span is filled in with the real input and output (`io.input` / `io.output`, taken from the events `send` returned; many instrumentations do not capture content by default, which does not affect this) | | Span status = error | Marked in red as an error | | None of the above (normalized to `other`) | Still shown on the timeline. Traces over 150 spans are collapsed | | Other attributes | Kept as is; click a span in `niceeval view` to see them | When the application instruments directly according to the GenAI semantic conventions (the "Your own gen\_ai instrumentation" tab above), all of this holds automatically. Common spans from AI SDK, LangSmith, and OpenLLMetry / OpenInference are also recognized automatically. ### Private instrumentation: write your own mapping A waterfall full of gray `other` usually means the application uses its own span names that automatic recognition does not know. There are two ways to fix it: * **Change the instrumentation (recommended)**: add a `gen_ai.operation.name` attribute to spans in the application. It is a one-line change, and your own observability backend benefits too. * **Write a `spanMapper`**: when changing the application is inconvenient, declare a pure function on the Agent that translates custom spans to the semantics in the table above before drawing. `tagSpan` writes the category back to the span, only adding attributes and never changing existing ones; `heuristicTag` is the same logic automatic recognition uses. Both are exported from `niceeval/adapter`: ```ts theme={null} import { completeEvidenceCoverage, defineAgent, tagSpan, heuristicTag } from "niceeval/adapter"; import type { TraceSpan } from "niceeval"; function mapMySpans(spans: TraceSpan[]): TraceSpan[] { return spans.map((span) => { // Private naming → standard semantics: op goes into gen_ai.operation.name, kind decides the color if (span.name === "my.llm.request") return tagSpan(span, { op: "chat", kind: "model" }); if (span.name === "my.tool.exec") { // Map the private call id to call_id so tool I/O can link to send events const attributes = { ...span.attributes, call_id: String(span.attributes?.["my.callId"] ?? "") }; return tagSpan({ ...span, attributes }, { op: "execute_tool", kind: "tool" }); } return tagSpan(span, heuristicTag(span)); // Leave the rest to automatic recognition }); } export default defineAgent({ name: "my-agent", evidenceCoverage: completeEvidenceCoverage, spanMapper: mapMySpans, async send(input, ctx) { /* Same as the integration above */ }, }); ``` `mapCodexSpans`, exported from `niceeval/adapter`, is a `spanMapper` implementation you can use as a reference. `spanMapper` only affects the waterfall. A wrong mapping only makes categories or colors inaccurate; it never affects assertions. ## Check the result After an eval runs, open the browser with `pnpm exec niceeval view` and go into an Attempt to see each turn's waterfall. In the terminal, use `pnpm exec niceeval show @` to see the Attempt's results and log notes. * **The waterfall is empty**: not a single span arrived during the whole run, usually because the application did not reach the receiver address, for example the environment variable was not set, or the service was not restarted after a config change. [NiceEval](https://niceeval.com/) notes this in the logs, and assertions are judged as usual. * **The waterfall occasionally misses its tail**: first check whether the application uses `BatchSpanProcessor`; see "Attribute spans to Turns" above. * **All spans show as `other`**: see "Private instrumentation: write your own mapping" above. ## What OTel integration does not handle * **All assertion data comes from `send`.** Tool calls must be mapped into `events` (with a built-in converter or handwritten mapping; see [Write send](/docs/tutorials/write-send)), and usage goes in the return value of `send`. Data that appears only in spans and not in `events` does not take part in assertions. * **Multi-turn sessions and HITL still happen in `send`.** Spans do not express "waiting for human input", and session continuation is decided by the application interface (for session continuation see [Write send](/docs/tutorials/write-send); for HITL see [HITL](/docs/explanation/hitl)). ## Related reading * [Connect Your Agent](/docs/tutorials/connect-your-agent): `send` event mapping and the data source for assertions. * [Write send](/docs/tutorials/write-send): the full tutorial for a handwritten Adapter; step six is the Adapter-side configuration for this page. * [Events reference](/docs/reference/events): the event structure assertions read. # Connect Your Agent: Choose an Integration and Pass Configuration Source: https://niceeval.com/docs/tutorials/connect-your-agent Choose a built-in adapter or a handwritten Adapter for the system under test, put the address and auth in the Adapter factory, pass model and flags to the application through ctx, and add tool calls, multi-turn, and tracing as needed. The Adapter in the [quickstart](/docs/tutorials/quickstart) hard-codes a `localhost` address and only handles single-turn conversations. Real projects soon run into these problems: local and staging addresses differ, you want to compare two models, and you want to check tool calls. This page shows how to turn that minimal Adapter into an integration you can keep using. ## Choose an integration first For applications built on the Vercel AI SDK, use a built-in adapter to connect to the existing HTTP interface directly, with no handwritten mapping. Use a built-in Sandbox Agent and run the Agent in an isolated environment. Handwrite an Adapter's `send` function. If the application already has OTel instrumentation, you can also [connect OTel](/docs/tutorials/connect-otel) to see the call waterfall. When the system under test is not a conversational Agent but an application that offers business operations such as posting or generating images, use `defineAdapter`; see [Evaluate a Custom Application](/docs/tutorials/custom-application). ## Connect to the real entry point When the system under test is a separate HTTP service, the Adapter should call the interface actually used in production, even if the service code and the evals live in the same repository. Do not switch to calling a function in-process for convenience: * The HTTP layer, serialization, middleware, and streaming are all bypassed, so a passing eval does not mean production behavior is correct. * An HTTP Adapter connects to local, staging, or production by changing one `baseUrl`; an in-process call can only run in the current codebase. When the Agent only provides an SDK, function, or CLI in the first place, call it directly in `send`. Do not build an extra service just to follow the HTTP pattern. Starting and stopping the service under test is not the Adapter's job. First start the service with the project's own dev command, deploy script, or CI, confirm the health check passes, then run the evals. When the service cannot start, let the eval report the error honestly. Do not pass off a hard-coded reply as a run result. For services that need to start and stop automatically for each eval run, see [Start Shared Experiment Services](/docs/tutorials/experiment-services). ## Two channels for configuration The easiest thing to get confused about in an integration is "where should this value come from". There are only two channels: 1. **Where to connect and how to authenticate goes through the Adapter factory.** Environment-level configuration such as URLs, auth headers, and protocol switches is written as factory parameters in the Experiment file. 2. **Which configuration to run this time goes through `ctx`.** NiceEval passes the Experiment's `model` and `flags` to `send` through `ctx` every turn. The Adapter does not interpret them; it forwards them to the application unchanged. Turn the quickstart Adapter into a factory: ```ts theme={null} // agents/my-agent.ts import { completeEvidenceCoverage, defineAgent } from "niceeval/adapter"; import type { Agent } from "niceeval/adapter"; export function myAgent(options: { baseUrl: string }): Agent { return defineAgent({ name: "my-agent", evidenceCoverage: completeEvidenceCoverage, async send(input, ctx) { const r = await fetch(`${options.baseUrl}/chat`, { // Where to connect: from the factory parameter method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ message: input.text, model: ctx.model, // Which model to run: from the Experiment flags: ctx.flags, // Which switches to turn on: from the Experiment }), signal: ctx.signal, // Timeout and cancellation }); const body = await r.json(); return { status: r.ok ? "completed" : "failed", events: [{ type: "message", role: "assistant", text: body.reply }], }; }, }); } ``` Call the factory in the Experiment, then declare `model` and `flags`: ```ts theme={null} // experiments/local.ts import { defineExperiment } from "niceeval"; import { myAgent } from "../agents/my-agent.ts"; export default defineExperiment({ agent: myAgent({ baseUrl: "http://localhost:3000" }), model: "gpt-5.4", flags: { promptVariant: "concise" }, }); ``` To evaluate local and production separately, write two Experiments that differ only in `baseUrl`: ```ts theme={null} // experiments/prod.ts export default defineExperiment({ agent: myAgent({ baseUrl: "https://api.example.com" }), model: "gpt-5.4", }); ``` ```bash theme={null} pnpm exec niceeval exp local pnpm exec niceeval exp prod ``` Do not put the URL on the command line. Positional arguments after the Experiment ID are only for filtering evals. Fields available on `ctx` each turn: | Field | Comes from | How the Adapter uses it | | - | - | - | | `signal` | NiceEval's timeout and cancellation | Attach it to every request you send | | `model` | The Experiment's `model` | Forward it if the application interface accepts a model choice; otherwise ignore it | | `flags` | The Experiment's `flags` | Forward unchanged, in the request body or a header | | `session` | One per conversation | Store and read state for multi-turn continuation and human intervention; see [Write send](/docs/tutorials/write-send) | | `telemetry` | Present when OTel integration is configured | Put the `traceparent` from `headers` into the request headers; see [OTel integration](/docs/tutorials/connect-otel) | | `progress(update)` | The current turn's call | Shows what is happening in the terminal; not saved | | `diagnostic(input)` | The current turn's call | Records an anomaly to review later; saved with the results | ## Report progress and anomalies When calling upstream is slow or the response is incomplete, report through `ctx` instead of calling `console.log` directly: ```ts theme={null} async send(input, ctx) { ctx.progress({ message: "Waiting for upstream model" }); const response = await callAgent(input, { signal: ctx.signal }); if (response.eventsIncomplete) { ctx.diagnostic({ code: "incomplete-event-stream", level: "warning", message: "Upstream response is missing tool result events", data: { requestId: response.requestId }, dedupeKey: `incomplete-event-stream:${response.requestId}`, }); } return toTurn(response); } ``` Handle three cases separately: * The Agent under test replied normally, but reports failure for this turn itself: return `status: "failed"`. The run reaches a conclusion, usually `failed`. * The connection failed or the response cannot be parsed, so the eval cannot continue: throw an exception. The run is recorded as `errored`, and the terminal gives a locator. * It can continue, but there is an anomaly worth recording: write a `ctx.diagnostic`. It does not change pass or fail. The terminal shows only a one-line error summary. For the full error message, stack, and diagnostics, use `niceeval show @`. ## Where to look first when it does not work * `fetch` throws directly, for example connection refused: the application is not running, or the URL is wrong. First send one request to the same interface with `curl` to confirm. * `t.succeeded()` fails: the request went out, but the Adapter returned `failed`. Check how the application's error response is mapped to `status`. * Only content assertions fail: the integration works. Use `niceeval show @ --execution` to see what the application actually returned, then decide whether to change the assertion or the application. ## Add more capabilities as needed A minimal integration can only check reply text. Each capability below changes only the Adapter, and existing evals need no changes: | What to check | What the Adapter does | How | | - | - | - | | Which tools were called, in what order, and what was not called | Map the application's tool calls to standard events | [Write send · Step 4](/docs/tutorials/write-send#step-4-parse-tools-into-events) | | Multi-turn conversations and parallel independent sessions | Continue context with `ctx.session` | [Write send · Step 2](/docs/tutorials/write-send#step-2-carry-earlier-messages) | | Tokens and cost | Return `usage` | [Write send · Step 3](/docs/tutorials/write-send#step-3-record-usage) | | Flows that need human approval to continue | Return `waiting` and say what it is waiting for | [HITL](/docs/explanation/hitl) | | Call waterfall in the browser | Have the application also send OTel spans to NiceEval | [OTel integration](/docs/tutorials/connect-otel) | | Compare two prompt versions or feature switches | The application makes variants switchable with `flags` | [Organize Experiments](/docs/tutorials/write-experiment#compare-two-prompt-versions) | For how much cooperation each capability needs from the application, see [Tier](/docs/explanation/tier). ## Reference implementations [`examples/zh/tier1`](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1) has five runnable integration examples: AI SDK v7, Claude SDK, Codex SDK, Pi SDK, and LangGraph. They cover tool call assertions, multi-turn isolation, HITL approve and reject, and the call waterfall. For the full fields of `send`, see [Adapter](/docs/explanation/adapter); for all official adapters, see [Official Adapters](/docs/reference/official-adapters). # Control the Cost of a Run Source: https://niceeval.com/docs/tutorials/control-run-cost One eval run can cost tens of dollars. Depending on what you want to control, use attempts, early exit, budget, concurrency, and result reuse. A full eval run calls the model hundreds of times, plus Sandbox costs, so the bill easily exceeds expectations. Before controlling cost, decide what you want to save: the number of repeated runs, a ceiling for when things go wrong, or the resources used at the same time. Each setting controls one thing, and they cannot replace each other. | Goal | How | | - | - | | Measure the full stability distribution | Set `attempts: N`; all of them run by default. | | Only confirm it can succeed at least once | Use `--early-exit`. | | Protect each Experiment's spending | Use `--budget `. | | Limit resources used at the same time | Use `--max-concurrency `. | | Skip results that were not affected | Use default reuse or `--rerun`. | | Spread Sandbox creation and preparation costs | Use `sandboxReuse`. | ## See how much will run before running Before spending money, confirm how many evals and Experiments this run selects, and how many times each eval runs: ```sh theme={null} pnpm exec niceeval exp regression --dry ``` `--dry` calls no model and creates no Sandbox. The first line gives the total number of Attempts in this plan, and each following line is one eval, marking which will reuse last results and which will run again. `--dry` does not predict the final cost. Its job is to catch a wrong scope or too many repetitions before dispatch. When the total is much larger than expected, check the Experiment's `evals` selection and `attempts` first. ## Stop at the first pass when you only care whether it can succeed When you only want to know "can this task be done" and do not care about the pass rate distribution, turn on early exit: ```sh theme={null} pnpm exec niceeval exp regression --early-exit ``` After one Attempt of an eval gets `passed`, that eval's Attempts that have not started are no longer dispatched and are recorded as `not-dispatched`. Attempts of the same eval that are already running receive a cancellation signal through `ctx.signal`; how quickly they end depends on whether the Adapter responds to that signal promptly. Attempts of the same eval that return after early exit triggers do not enter the results. `failed`, `errored`, or `skipped` do not trigger early exit. Do not turn this on when you need the full pass rate distribution. Under default concurrency, several Attempts of the same eval may already be running at once, so nothing is saved; to make it really save money, see [Make early exit actually save money](/docs/tutorials/concurrency#make-early-exit-actually-save-money). ## Set a protective spending ceiling When you worry that a run might get out of hand and spend more than expected, set a dollar ceiling: ```sh theme={null} pnpm exec niceeval exp regression --budget 25 ``` The budget is calculated separately for each Experiment ID. As each Attempt completes, NiceEval estimates its cost (`estimatedCostUSD`) from the Experiment's `model`, the reported token usage, and the price table, then adds it to the budget. The actual amount `usage.costUSD` reported by a Provider or Adapter is only for display and does not take part in the budget decision. When the cumulative estimate reaches the ceiling, NiceEval stops dispatching new Attempts; those already running are not stopped midway. So `--budget` is a protective ceiling, not an exact bill: while several concurrent tasks have not settled, the final cost can briefly exceed the setting. When the budget runs out, you see: * a `budget_exhausted` diagnostic; * Attempts that were not dispatched in time recorded as `not-dispatched`; * the command exiting with a non-zero status. After raising the budget, rerun the same scope. Attempts that already completed and qualify for reuse are reused directly, and only the remainder runs. When `model`, token usage, or a matching price is missing, NiceEval cannot compute an estimated cost, and `usage.costUSD` cannot fill the gap. The run then gives a warning instead of pretending the budget is still in effect. When you see this warning, first confirm the Experiment sets `model` and the Adapter reports token usage. ## Concurrency only controls resource use When the local machine or the Provider cannot keep up, limit the number of Attempts running at once: ```sh theme={null} pnpm exec niceeval exp regression --max-concurrency 4 ``` A concurrency limit is not a cost limit; cost protection still uses `--budget`. Sandbox build concurrency is controlled by a separate flag, `--max-build-concurrency`. To also spread out the creation and preparation time of each Sandbox, see [Reuse Sandboxes](/docs/tutorials/sandbox-reuse). To choose between rerunning only failures and revalidating everything, see [Rerun and Reuse Results](/docs/tutorials/rerun-and-cache). ## See how much was spent After the run, view the cost of one Attempt or a whole experiment in the terminal: ```sh theme={null} pnpm exec niceeval show @attempt-locator pnpm exec niceeval show --experiment regression ``` To browse in the browser, run `pnpm exec niceeval view`. The Attempt page first shows the combined cost of the application and the Judge: * When the cost is complete, it shows the total. * When receipts, prices, or complete call records are missing, it shows the known subtotal and which parts are missing. * An explicitly recorded zero amount counts as a known cost. Money already spent on failures and retries is kept too. Model purpose, each actual call, and Judge costs are in the breakdown below. The experiment page sums the latest result of each Attempt of each eval, counting the same original Attempt only once. Old results replaced by a rerun are not added, and results that were not rerun are kept. So it shows the cost of the current batch of results, not a bill for all historical runs. Omitting a few rows in the breakdown does not reduce the total; different currencies are shown separately without exchange-rate conversion. ## Have a custom Adapter report cost For costs from your own application Adapter to appear on the pages above, report the usage of each call: * Use `ctx.recordUsage(call)` to report the final snapshot of each physical call. * After all records are obtained, call `ctx.sealUsage({ state: "complete" })` to tell NiceEval this record is complete. * When only part of the records could be read, call `ctx.sealUsage({ state: "partial", reason: "source-incomplete" })`. An empty record that was not declared complete shows as an unknown cost, never as zero. # Score with Hidden Tests and Rerun Automatically When Tests Change Source: https://niceeval.com/docs/tutorials/criteria-files Use loadText to read scoring files such as hidden tests and test scripts. NiceEval remembers the file content, so when a test changes, the evals that use it rerun automatically next time. Coding tasks often use a hidden test to score. Only after the Agent finishes changing the code does the eval write the test file into the Sandbox and run it; if the test passes, the eval passes. The Agent cannot see this test while it works, so it cannot game it. This test file is the scoring standard. Changing it amounts to swapping the task, and old results should no longer count. Read it with `loadText`, and NiceEval records the file content and counts it as an input of this eval. Change the file by one byte, and this eval reruns automatically on the next run while other evals keep reusing their old results. If you read it yourself with `fs.readFile`, you get the same content, but NiceEval does not know about the read. When you change the test file, it still reuses old results, and you see old conclusions judged by the old test. ## Read scoring files with loadText 1. Put scoring files in the repository next to the evals, not in the starting project given to the Agent: ```text theme={null} evals/ react-datepicker/ pr-6058.eval.ts fixtures/react-datepicker/pr-6058/ tests/datepicker_test.test.tsx # Hidden test tests/run-tests.sh # Test script ``` 2. Read them at the **module top level** of the `.eval.ts`. NiceEval needs to know the scoring file content before the run to decide whether this eval must rerun: ```typescript theme={null} import { defineEval } from "niceeval"; import { loadText } from "niceeval/loaders"; import { commandSucceeded } from "niceeval/expect"; const fixture = (p: string) => new URL(`../fixtures/react-datepicker/pr-6058/${p}`, import.meta.url); const hiddenTest = await loadText(fixture("tests/datepicker_test.test.tsx")); const runTests = await loadText(fixture("tests/run-tests.sh")); ``` A path string relative to the project root also works: `loadText("evals/fixtures/react-datepicker/pr-6058/tests/run-tests.sh")`. `loadText` accepts a `URL` object directly; you do not need to import `node:url`. 3. In `test(t)`, wait until the Agent is done, then write the tests into the Sandbox and run them: ```typescript theme={null} export default defineEval({ description: "react-datepicker pr-6058", async test(t) { await t.send("Fix the changeMonth panel misalignment. Do not modify test files."); await t.sandbox.writeText("src/test/datepicker_test.test.tsx", hiddenTest); await t.sandbox.writeText("tests/run-tests.sh", runTests); t.check(await t.sandbox.runCommand("bash", ["tests/run-tests.sh"]), commandSucceeded()); }, }); ``` Repository tasks such as Terminal-Bench work the same way. When preparing the Agent's starting environment, include only the public Fixture; do not put hidden tests or test scripts into the image, working directory, or prompt. After `t.send(...)` returns, upload the original files read by `loadText` at the module top level into the Sandbox, run the original test script, and check both the expected results and the anti-leak check script. That way, changing a hidden file triggers a rerun, and the Agent cannot see it while working. ## Confirm that changing a test triggers a rerun Change `datepicker_test.test.tsx` a little, then preview the run plan: ```sh theme={null} pnpm exec niceeval exp --dry ``` This eval should show `execute`, and other unaffected ones should show `reuse`. After confirming, drop `--dry` to run for real, then view the results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. For how to read the plan, see [Rerun Only the Evals Affected by a Change](/docs/tutorials/rerun-and-cache). When you changed a test but it shows `reuse`, first check whether the file was read with `fs.readFile`. ## Common mistakes * **`loadText` must be at the module top level.** Whether to rerun is decided before the run starts; reading in `test(t)` only happens at run time, which is too late. NiceEval reports an error directly and tells you to move it to the top level. * **Read files the Agent changed with `t.sandbox`, not `loadText`.** Files in the Sandbox are this run's results, different each time, and should not decide whether to rerun. Read them with `await t.sandbox.readText(path)`, then pass the string to `t.check`. * When the scoring standard is structured data (such as a case lookup table), use `loadYaml` / `loadJson`; see [Data-Driven Tests](/docs/tutorials/dataset-fanout). # Evaluate a Custom Application: Business Operations Such as Posting and Image Generation Source: https://niceeval.com/docs/tutorials/custom-application When the application under test is not a conversational Agent, use defineAdapter to expose its business methods directly to evals, and check structured results and business operations. You are evaluating an AI feature in a social application: after a user posts, AI generates an image and suggests replies. There is no "send a message, get a reply" conversation here; there are business operations such as posting, replying, and generating images. Forcing them into `t.send("post this for me")` is awkward. A more direct approach is to use `defineAdapter` to hand the application's own methods to the eval: every operation in the returned object becomes a typed `t.post(...)` or `t.generateImage(...)` in the eval. The `defineAgent` used by conversational Agents is a special case of Adapter that must implement `send`, multi-turn sessions, and Turns. A custom application does not need to implement any of these. ## Define the application connection First tell NiceEval how to create your application and which of its operations the evals use. The example below connects an existing social application. `createSocialApp` belongs to your application, and the returned object lists each operation the evals need. ```ts application.ts theme={null} import { defineAdapter } from "niceeval"; import { createSocialApp } from "./src/social-app.ts"; export const social = defineAdapter({ name: "social-app", create(context) { const app = createSocialApp({ signal: context.signal }); context.onCleanup(() => app.close()); return { post: (text: string) => app.publishPost(text), reply: (postId: string, text: string) => app.reply(postId, text), visitDiscoveryPage: () => app.visitDiscoveryPage(), generateImage: (prompt: string) => app.generateImage(prompt), }; }, }); ``` The runner calls `create` once per Attempt and runs the registered `onCleanup` callbacks when the Attempt ends. * Whether each Attempt gets its own application instance is up to your `create`. The runner does not copy instances for you. * The runner does not release resources that were not registered with `onCleanup`. Register them right after acquiring them. * Pass `signal` into the application's network requests and long tasks so they stop on timeout or cancellation. TypeScript infers each method's parameters and results from the returned object, so you do not need to write the type of `t` by hand. When the returned object has no `send`, writing `t.send` in an eval is a type error. Built-in NiceEval names such as `check` cannot be overridden. For an existing class instance, expose its methods one by one with the closure style above. ## Call application actions and write custom checks Next, call these operations in the eval and check the results. `test(t)` has both the application's operations and NiceEval's check methods. Objects the application returns, such as a Post or Reply, can be passed directly to a custom Match defined with `defineValueMatch`. ```ts evals/publish.eval.ts theme={null} import { defineValueMatch } from "niceeval/expect"; import { social } from "../application.ts"; const hasText = defineValueMatch<{ text: string }>({ name: "Post has text", evaluate: (post) => post.text.trim().length > 0, }); const hasReplyTo = (postId: string) => defineValueMatch<{ replyToId: string | null }>({ name: "Reply links to the target post", evaluate: (reply) => reply.replyToId === postId, }); export default social.defineEval({ async test(t) { const post = await t.post("Invite everyone to watch the meteor shower this weekend"); t.check(post, hasText).gate().label("Post generated"); const reply = await t.reply(post.id, "Ask where to meet"); t.check(reply, hasReplyTo(post.id)).gate().label("Reply relationship is correct"); }, }); ``` The parameters and return values of operations are decided by your application. `defineValueMatch` only describes what makes a value match. `t.check` registers an assertion, and `.gate()` means that if it fails, the whole eval fails. String, number, and Schema matchers work directly too. When you need to accumulate a score, switch to `social.defineScoreEval` and call `.score(points)` on the checks. Open-ended standards such as tone, character consistency, and content quality can be handed to the [Judge](/docs/explanation/judge). ## Define assertion methods on the connection When several evals need the same kind of check, write it as an assertion method on the Adapter, and evals can call it in one line. Define these methods in `assertions`. They receive the current Attempt's `app` and `check`, and return the result of `check`. ```ts application.ts theme={null} import { defineAdapter } from "niceeval"; import { defineValueMatch } from "niceeval/expect"; import { createSocialApp } from "./src/social-app.ts"; const hasText = defineValueMatch<{ text: string }>({ name: "Post has text", evaluate: (post) => post.text.trim().length > 0, }); export const social = defineAdapter({ name: "social-app", create(context) { const app = createSocialApp({ signal: context.signal }); context.onCleanup(() => app.close()); return { post: (text: string) => app.publishPost(text), readPublishedPost: () => app.readPublishedPost(), }; }, assertions({ app, check }) { return { hasPublishedText() { return check(app.readPublishedPost(), hasText); }, }; }, }); ``` ```ts evals/publish.eval.ts theme={null} import { social } from "../application.ts"; export default social.defineScoreEval({ async test(t) { await t.post("Invite everyone to watch the meteor shower this weekend"); t.hasPublishedText().gate().score(1).label("Post generated"); }, }); ``` `hasPublishedText()` has the same effect as writing `t.check` directly, with the same scoring and the same display in reports. Assertion methods in `defineEval` have no `.score()`; those in `defineScoreEval` do. Operation methods such as `post()` still only call the application. When writing assertion methods: * Read the data to check only when the method is called. `assertions` only assembles methods; do not call `check` ahead of time inside it. * Put only functions in the returned object, not getters or other values, and method names must not collide with application operations or built-in NiceEval names. * Methods return the result of `check` synchronously, not a Boolean or a Promise. Put asynchronous operations and waiting in application methods and finish them before checking. * When data is incomplete, report a clear error. Do not judge it as "nothing happened". * Write parameters with concrete types. Generic or overloaded assertion methods cannot be called; for such checks, use a separate Match with `t.check`. Application operations themselves can be generic methods. ## Choose an implementation and run Write an Experiment that selects this application, then run it. ```ts experiments/local.ts theme={null} import { defineExperiment } from "niceeval"; import { social } from "../application.ts"; export default defineExperiment({ adapter: social, attempts: 1, }); ``` ```bash theme={null} pnpm exec niceeval check pnpm exec niceeval exp local ``` After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. The application the Experiment selects must provide the operations the evals use. When an operation is missing, NiceEval reports an error before creating the application; check the object returned by `create` first. The order, parameters, and repetitions of operations are all decided by the eval code. A failed application operation is not retried automatically the way Agent messages are. You can also name an operation `send`; its parameters and return value are still decided by the application, and you do not need to construct a `Turn`. ## Compare two implementations with the same evals To find out whether another implementation does better or worse, have both implementations run the same set of evals. First declare their shared interface, then provide each implementation: ```ts theme={null} import { defineAdapterContract } from "niceeval"; import type { SocialContext } from "./src/social-app.ts"; export const social = defineAdapterContract({ name: "social/v1" }); export const baseline = social.implement({ name: "baseline", create: createBaseline }); export const candidate = social.implement({ name: "candidate", create: createCandidate }); ``` Write evals with `social.defineEval`, and have two Experiments select `baseline` and `candidate` respectively. Evals can only use methods from the shared interface. Interfaces obtained from two separate `defineAdapterContract` calls are not interchangeable even if they have the same name. When the two implementations need to share assertion methods, call `withAssertions` before creating the implementations: ```ts theme={null} const social = defineAdapterContract({ name: "social/v1" }) .withAssertions(({ app, check }) => ({ hasPublishedText() { return check(app.readPublishedPost(), hasText); }, })); ``` Create implementations and evals from the interface `withAssertions` returns, and every implementation uses the same checks. `withAssertions` returns a new interface; implementations created before it do not get these methods. ### When last results are reused When running again, NiceEval decides whether last results can be reused. The decision uses the Adapter's `name`, `contract`, and `behaviorRevision`, plus the Experiment's `flags`. When the eval file, or a file it statically imports by relative path within the project, changes, that eval reruns. NiceEval cannot see changes to an implementation the Experiment selects separately, dynamic imports, external packages, or remote services. When you change such behavior, raise `behaviorRevision` or reflect the change with explicit configuration. A custom Adapter that does not declare `behaviorRevision` reruns every time and never reuses last results. Use `pnpm exec niceeval exp local --dry` to see first which will be reused and which will rerun; see [Rerun Only the Evals Affected by a Change](/docs/tutorials/rerun-and-cache). ## Save the application's execution trace When an eval fails, you want to know what the application actually did internally. Hand the application's execution log to NiceEval, and you can then inspect it entry by entry with `niceeval show` instead of digging through logs on the application side. After the application finishes and you have the complete log, call `ctx.recordTrace` in the Adapter. Each event keeps its own type, actor, summary, and known relations; it does not need to be wrapped as a chat message. The event below only means the post request was accepted; actual publication needs a separate event. ```ts theme={null} const attachment = await ctx.attach({ name: "events.json", mediaType: "application/json", body: JSON.stringify({ events: archivedEvents }), }); const receipt = await ctx.recordTrace({ traceId: sessionId, schema: { id: "social-app.execution" }, collection: { state: "complete", limitations: [] }, scopes: [], events: [{ key: "accepted", type: "post.accepted", source: { id: "server", eventId: archivedEvents[0].eventId }, actor: { id: "author", label: "Author" }, summary: "Post accepted for publication", evidence: [{ key: "original", label: "Original event", artifactId: attachment.artifactId, pointer: "/events/0", }], }], }); ``` * `key` lets events in this submission reference each other. Fill `source.eventId` only with an ID the application really provides; omit it otherwise. * `schema.id` names the application's event format. Fill only `id`, not `revision`. * The returned `receipt.events` gives each event's `eventId`, which you use to expand it when viewing. Submitting the same content again with the same `traceId` returns the same `eventId`; different content is rejected. * Summaries have a length limit. Put large inputs in an attachment and use the `pointer` in `evidence` to point at the exact location in the attachment, so viewing goes straight there without scanning the whole log. After the run, view this Attempt's execution, then expand an event: ```sh theme={null} pnpm exec niceeval show @ --execution pnpm exec niceeval show @ --execution --expand ``` `collection.state` describes whether the log is complete, regardless of whether the run was canceled. If you still got the complete log after cancellation, write `complete`. When the log is missing parts, write `partial` and explain what is missing in `limitations`; do not use an empty list to mean "unknown". Events outside the `scopes` collection range are kept too, marked `excluded`, and those that cannot be determined are marked `unknown`. Submitting an execution log does not affect scoring by itself. `onCleanup` callbacks have their own time limit, and you can submit execution logs inside them too. Saved execution logs and attachments are stored with the result file `.niceeval/record.sqlite`, and viewing them never calls the application or model again. ## Archive large attachments Large files such as raw application logs and network responses can be saved as attachments with the results and pulled out during debugging. Use `ctx.attach` to hand a file, network response, or generator to NiceEval as a byte stream; NiceEval does not parse its format: ```ts theme={null} import { createReadStream } from "node:fs"; const receipt = await ctx.attach({ name: "journal.ndjson", mediaType: "application/x-ndjson", body: { stream: signal => createReadStream(localPath, { signal, highWaterMark: 64 * 1024, }), }, signal: ctx.signal, }); ``` Stop writing to the local log first, then submit the attachment, and do not change the source file while it is being read. The returned receipt includes `artifactId`, the `sha256` of the raw bytes, and `byteLength`. Once you have the receipt, NiceEval has its own copy, and deleting or rewriting the source file afterwards does not affect the attachment. The attachment is written into the result file when this Attempt's results are saved, and is never uploaded to any remote service. Size limits: * Byte streams are at most 1 MiB per chunk, at most 1 GiB per attachment, at most 2 GiB of attachments in total per Attempt, and at most 4 streams submitted at once. * String and array attachments are at most 64 MiB each and at most 256 MiB in total. * An attachment used as `pointer` evidence in an execution log cannot exceed 64 MiB. NiceEval reads the next chunk only after writing the previous one, and never reads the whole file into memory. Custom streams must respond to the `signal` passed in and release their own files or connections. When submitting attachments in `ctx.onCleanup`, use the `signal` provided by the cleanup callback, not the already-canceled `ctx.signal`. When a submission fails, this Attempt shows a collection failure even if you catch the error, and attachments submitted successfully before are kept. Cleanup has only 30 seconds in total, so submit large files during the run. ## Read the results View this run with `pnpm exec niceeval show`. Each `t.check` saves the checked value (with a length limit) and the verdict. Sessions, tool calls, and costs that NiceEval did not collect show as missing and are never filled in as zero. Having no Turn does not mean zero tokens were used, and the Judge's cost does not equal the cost of the whole application. Because the application's full cost is unavailable, a custom application cannot set `budget`, and cannot declare Sandbox or session capabilities that only Agents have. Lifecycle Plugins need the Agent's execution preparation steps; configuring them on a custom application reports an error before the run. The full runnable simulated X example is in the [LLM X source](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/llm-x). It uses an LLM to generate characters, profile pages, posts, images, and follow-up social activity, and evaluates them with the application's native methods. # Data-Driven Tests: Generate Many Evals from One Dataset Source: https://niceeval.com/docs/tutorials/dataset-fanout When dozens of evals share the same logic and differ only in input, export an array or object from one .eval.ts and generate many evals from one set of logic. You can also read an external dataset with loadYaml or loadJson and give each case a stable ID. You are evaluating a SQL generation feature and have 80 "question + expected result" samples. The check logic is the same for each: send the question, run the SQL, compare the result. If you write one file per sample, changing the check logic once means changing it in 80 places. Data-driven tests let you write the logic once and generate many evals from a dataset. Large sets of same-shaped tests such as intent classification, retrieval QA, and tool selection are a good fit. ## Export a set of evals from one file Put the samples in an array, use `map` to generate a `defineEval` for each row, and default-export the whole array. The check logic is written once, and changing it applies to every sample: ```ts theme={null} import { defineEval } from "niceeval"; import { equals } from "niceeval/expect"; const rows = [ { task: "Count users", prompt: "Count all users", sql: "SELECT COUNT(*) FROM users;" }, { task: "Recent orders", prompt: "Find recent orders", sql: "SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;" }, ]; export default rows.map((row) => defineEval({ description: row.task, async test(t) { await t.send(row.prompt); t.check(t.reply, equals(row.sql)); }, }), ); ``` ## Confirm each eval's ID When you export an array, NiceEval numbers each eval by position. If the file is `evals/sql.eval.ts`, the generated IDs are: ```text theme={null} sql/0000 sql/0001 ``` The numbers are zero-padded to a fixed width, which makes them easy to reference and filter by prefix. Note that when the array order changes, the same sample's ID changes with it. When the data source already has stable case, issue, or benchmark IDs, export an object instead and use those IDs as keys: ```ts theme={null} export default Object.fromEntries( rows.map((row) => [ row.issueId, defineEval({ description: row.title, async test(t) { await t.send(row.prompt); t.succeeded(); }, }), ]), ); ``` When the file is `evals/swelancer.eval.ts` and the key is `15193`, the ID is `swelancer/15193`. A key must be one non-empty path segment: it cannot be `.` or `..`, and cannot contain `/`, `\\`, or control characters. NiceEval sorts keys lexicographically, so when the data source returns them in a different order, the run order and IDs stay the same. Before running, use `pnpm exec niceeval exp local sql --dry` to confirm which IDs were generated and how many there are. ## Read a dataset from YAML or JSON As samples grow, a separate data file is easier to maintain. Read it with `loadYaml` or `loadJson`, passing a validation function: ```ts theme={null} import { loadYaml } from "niceeval/loaders"; import { z } from "zod"; const SqlCases = z.object({ cases: z.array(z.object({ task: z.string(), prompt: z.string(), sql: z.string() })), }); const doc = await loadYaml("evals/data/sql-cases.yaml", (value) => SqlCases.parse(value)); const rows = doc.cases; ``` ```yaml theme={null} cases: - task: Count users prompt: Count all rows in the users table sql: SELECT COUNT(*) FROM users; ``` `loadYaml` and `loadJson` require a validation function (decoder). The file content only appears as an unknown type inside the validation function; once validation passes, the return value is typed data you can use directly. When the data file has the wrong format, the validation function throws, and this eval file fails to load. ## Run only part of the dataset Write an eval ID prefix after the Experiment to run only some of them: ```bash theme={null} # Run the whole dataset pnpm exec niceeval exp local sql # Run only the first entry in the array pnpm exec niceeval exp local sql/0000 # Run one entry from an object export pnpm exec niceeval exp local swelancer/15193 ``` After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. Each sample is an independent eval, and the failing ones are listed separately. ## Choose a dataset or separate files Every sample follows exactly the same flow; only the input and expected output differ. Each case has a different flow, different assertions, or needs a different Agent configuration. A dataset suits covering one feature with many similar samples; separate eval files suit scenarios with complex flows. # Check How the Sandbox Will Be Prepared Before Running Source: https://niceeval.com/docs/tutorials/debug-lifecycle-plan Use niceeval debug to list the order in which one eval is prepared, installed, executed, and cleaned up in the Sandbox, with each step's actual parameters, without creating any resources. You wrote `.before()` on the Experiment, the eval group, and the eval, and configured installation steps for the Agent. Before actually running, you want to confirm the final order of these steps and each step's parameters, instead of finding out the order is wrong after the Sandbox is built and dependencies took five minutes to install. `exp --dry` tells you which evals run and how many times. `niceeval debug` goes further: it lists one eval's complete preparation, installation, execution, and cleanup steps in the Sandbox, without creating any Sandbox or calling any model. ## Pick one Experiment and one eval `debug` looks at one eval under one Experiment at a time: ```sh theme={null} pnpm exec niceeval debug compare/codex memory/commit0 ``` Each argument must match exactly one item. An exact ID wins; when a prefix matches several, the CLI lists the candidates and stops, and you copy the full ID from them and run again. The eval is looked up only within the scope this Experiment already selects. When you pick one eval from an eval group, the plan does not add the other evals in the group, but it still shows the preparation steps the eval group contributes. ## Read the plan in order The output uses nested boxes: the outermost is the Experiment, and inside it come each Attempt and its lifecycle steps. Each `sandbox.before` step shows: * who declared this step (`owner`) and its ID (`action`); * its final execution number, and which eval it belongs to; * the `changeFrequency` value you set, where it came from, and the matching preset; * which steps it depends on, why it sits in this position, and the level it runs at (`occurrence`, currently always `attempt`); * whether the Provider can cache this step, and the cache status not yet checked at run time; * whether this step can be restored from cache (`declared`, `cumulative`, `providerCoverage`, and `barrier` in `cache.state`). ```text theme={null} ╭─ sandbox.before ───────────────────────────────── EXACT ─╮ │ owner: experiment:compare/codex │ │ action: install-runtime │ │ family: @acme/install-runtime │ │ execution order: #2 · compare/codex > memory/commit0 │ │ change frequency: 20 · explicit │ │ scheduled by: lowest ready changeFrequency │ │ occurrence: attempt │ │ cache: not-probed · persistent · runtime pending · final key not-probed │ │ prefix identity: linked-prefix:… │ │ cache state: declared dockerData · cumulative dockerData │ │ provider coverage dockerData · barrier none │ ╰─────────────────────────────────────────────────────────╯ ``` `execution order` is the real run-time order, not the order you wrote in code. The rules: dependencies always run first; when several steps can run, the one with the smaller `changeFrequency` value runs first; when the values are equal too, the declarer and declaration order decide. So a rarely changing step in an eval group can come before a frequently changing step in the Experiment. ## Tell which steps can be restored from cache * `declared` is the state this step itself declares it will change. * `cumulative` includes the state changed by this step and every preparation step before it. * `providerCoverage` describes what the Provider can actually restore. * When `barrier` is not `none`, the output also gives `barrierActionId`, pointing to the first step that cannot be cached. The reason may be that its state declaration is the default `all`, that it is `opaque`, or that the Provider does not support it. This step and every step after it really execute. ## Confirm commands and parameters Hard-coded command steps show the shape of the command. Ordinary callbacks are marked `OPAQUE`, because they can only run at run time and `debug` cannot see what they do. Environment variables show only the variable name and a digest, and stdin shows only a digest and byte count. Credential values, raw stdin, and unsafe remote addresses never appear in the terminal or JSON output. ## Let tools read the plan ```sh theme={null} pnpm exec niceeval debug compare/codex memory/commit0 --json > debug-plan.json ``` The JSON is the same plan as the terminal output, without the box lines. Each step carries `declarationOrder`, `executionOrder`, `changeFrequency`, `dependencies`, `occurrence`, and `cache`. `cache.state` always gives `declared`, `cumulative`, `providerCoverage`, and `barrier`, plus `barrierActionId` when there is a barrier; steps that can be determined also give `cache.prefixIdentity`. `debug` does not read the run-time cache, so `cache.lookup` and `cache.runtime.finalKey` are always `not-probed`, and `cache.runtime.status` is always `pending`. `debug` accepts only `--json`. Run parameters such as budget, rerun, and concurrency do not apply to it. ## It creates no resources `debug` does not run before callbacks, test, after, ensure, or Provider cleanup functions, and does not create Runs, locks, Sandboxes, or build jobs, or write to the result file. It does load your Experiment definition and run the `evals` selection function. Providers may also read local files, call read-only CLIs, or query remote services during planning, so still run it only in trusted projects. To confirm which evals run and how many times each, see [Confirm What Runs and How Many Times Before Running](/docs/tutorials/select-and-preview). When a real run fails and you need to get into the scene, see [Keep the Sandbox to Debug Problems](/docs/troubleshooting/debug-sandbox). # Let the Sandbox Use Docker Source: https://niceeval.com/docs/tutorials/docker-in-docker Choose a Docker socket, raw privileged DinD, or managed rootless DinD based on the task's trust boundary, and prepare the Docker CLI and daemon for the Agent. When an eval task requires the Agent to run `docker build`, `docker run`, or `docker compose`, first choose Docker access based on the task's trust boundary: | Mode | When to use it | Startup cost | Security boundary | | - | - | - | - | | Docker socket | Trusted Agent, personal dev machine, existing daemon, startup speed first | Lowest; shares the existing daemon | The Agent has full control of that daemon. A rootful socket is usually equivalent to host root | | Raw privileged DinD | Disposable VM or dedicated runner that needs separate images, networks, and cache | Starts one Docker daemon per Sandbox | The outer container runs raw privileged; this is not a security isolation solution | | Managed rootless DinD | Untrusted Agents, shared hosts, concurrent evals that need resource limits and reclamation after forced kills | Highest; the host must deploy a profile first | privileged takes effect only inside a managed rootless user namespace or a dedicated VM, and a host service handles capacity limits and reclamation | When unsure, decide in this order: if the Agent is trusted and runs only on your own machine, choose Docker socket; for a disposable VM or dedicated runner, choose raw privileged DinD; for a shared host or an untrusted Agent, choose managed rootless DinD. Managed rootless DinD needs a Docker profile installed on the host first. It depends on Linux cgroup v2, systemd, and a filesystem that supports project quota. NixOS can deploy it with the NiceEval module; Ubuntu, Debian, and other systemd Linux distributions need an equivalent host service installed yourself. On macOS, the practical choices are OrbStack or Apple `container`: * OrbStack's Docker engine can be used for the Docker socket mode with trusted tasks. When you need a full profile, run NiceEval inside an OrbStack Linux machine and deploy the host service the way that distribution's Linux installation does. * Apple's official [`container`](https://github.com/apple/container) runs Linux containers as lightweight VMs on Apple silicon and macOS 26. The current NiceEval Docker Provider does not drive its CLI or API, so you cannot select `container` directly; that needs a separate Provider. Apple `container` uses the Containerization framework, not containerd. Installing containerd directly does not give Darwin Linux cgroups or project quota either; it still needs a Linux VM. Whichever mode you choose, the image must have the Docker CLI preinstalled. NiceEval does not install Docker for you at run time. Both DinD modes also require the image to derive from the official `docker:-dind`, with Node and the tools the eval needs installed. You do not need to write your own `ENTRYPOINT`: NiceEval handles starting and supervising the Docker daemon in the Sandbox, keeping the Sandbox alive, the in-container TTL, and the `docker info` check. Do not use `docker:-dind` directly as `source.image`. That image has neither the Node NiceEval needs nor the tools of the Agent under test, and creating the Sandbox fails with `dind-image-incompatible: missing node`. Derive an image with the Dockerfile below, or publish an equivalent derived image first, and reference it in `source`. ## Option 1: Mount the Docker socket directly Choose this mode when the Agent is trusted and you just want it to use the Docker already on the machine. It starts fastest, because no extra daemon is needed. First prepare an image with only the Docker CLI, Node, and eval tools: ```dockerfile title="sandbox/Dockerfile" theme={null} FROM docker:29-cli RUN apk add --no-cache ca-certificates git nodejs npm python3 \ && addgroup -g 1000 node \ && adduser -D -u 1000 -G node node ``` Then spell out the host's Unix socket path in the Experiment. NiceEval does not guess this path from environment variables or the Docker context: ```ts title="experiments/docker-socket.ts" theme={null} import { defineExperiment } from "niceeval"; import { codexAgent } from "niceeval/adapter"; import { changeFrequency, dockerSandbox, shell, } from "niceeval/sandbox"; export default defineExperiment({ agent: codexAgent(), model: "gpt-5.4", sandbox: dockerSandbox({ source: { type: "dockerfile", context: new URL("../sandbox/", import.meta.url), }, user: "node", dockerAccess: { mode: "socket", socketPath: "/var/run/docker.sock", }, }), }); ``` At run time, NiceEval: * resolves symlinks and confirms the path finally points to a Unix socket; * mounts it at the fixed `/var/run/docker.sock` in the Sandbox; * adds the `node` user to the group matching the socket's numeric GID. Before the Agent starts, NiceEval also confirms that the image does not preset a Docker endpoint or context, and that running `docker info` directly connects to the daemon behind this socket. When the image lacks the Docker CLI, the user cannot access the socket, or the image moves the default endpoint elsewhere, Sandbox creation fails. This check only guarantees that the Agent uses this daemon by default; it is not a security boundary. The Agent can still connect elsewhere later with `docker --host`. Untrusted tasks rely on the network limits of managed mode, not on Docker CLI defaults. This is not an isolation solution. Through the socket, the Agent can create privileged containers, mount host directories, and operate other containers, images, volumes, and networks on the same daemon. Do not mount a rootful host socket for an untrusted Agent. ## Option 2: Docker-in-Docker Choose Docker-in-Docker (DinD) when each Sandbox needs its own separate Docker. Each Sandbox starts its own Docker daemon, and images, networks, and cache are not shared with other Sandboxes. First create the image: ```dockerfile title="sandbox/Dockerfile" theme={null} FROM docker:29-dind RUN apk add --no-cache ca-certificates git nodejs npm python3 \ && addgroup -g 1000 node \ && adduser -D -u 1000 -G node node \ && addgroup node docker ``` For formal evals, pin `FROM` to a reviewed digest and install the CLIs the Agent under test needs into the image ahead of time. ### How NiceEval starts DinD Once you write `dockerAccess: { mode: "dind", ... }`, NiceEval takes over how the image starts: 1. It ignores the image's original `ENTRYPOINT` and `CMD`. 2. It checks that the image has `docker-init`, `node`, `docker`, `dockerd-entrypoint.sh`, `timeout`, and `tail`. 3. It starts a supervisor process that watches both the official `dockerd-entrypoint.sh dockerd` and the Sandbox keep-alive process. If either process exits early, the whole outer container stops. You never get a Sandbox that looks alive but cannot use Docker. The Docker daemon in the Sandbox listens only on `/var/run/docker.sock` and does not open TCP ports 2375/2376. The Agent still runs as `user: "node"`. The Dockerfile above adds `node` to the `docker` group at build time, so you do not need `chown root:node` or `chmod 666` on the socket. Do not set `DOCKER_HOST` or `DOCKER_CONTEXT` in the derived image. NiceEval first confirms the default context is used, then checks that running `docker info` directly and explicitly accessing `/var/run/docker.sock` reach the same daemon. Only after this check passes does it run the command you wrote in `readiness`. When the image lacks tools, the user is not in the `docker` group, the daemon exits early, or `readiness` times out, NiceEval collects a log tail before deleting the container and states the reason in the error. For troubleshooting, see "Locate DinD creation failures" below. The image's original startup is not kept. To evaluate a set of services that must start with their own `ENTRYPOINT` / `CMD`, use the Docker Compose Sandbox instead, and let the Compose file declare how those services start. ### Prepare the Docker environment before the Agent starts The Dockerfile holds only fixed tools and the project's initial files. Operations that need the Docker daemon in the Sandbox go in the Sandbox's `.before()` preparation step, not in the image's `ENTRYPOINT`. The preparation step below creates a writable workspace and confirms the Docker daemon is available. When the image really contains the matching archive or files, add `docker load`, file copies, or project self-check commands here: ```ts title="lib/dind-sandbox.ts" theme={null} import { changeFrequency, dockerSandbox, shell } from "niceeval/sandbox"; export const dindSandbox = dockerSandbox({ source: { type: "dockerfile", context: new URL("../sandbox/", import.meta.url), }, user: "node", dockerAccess: { mode: "dind", isolation: "raw-privileged", }, readiness: { command: ["docker", "info"], user: "node", timeoutMs: 30_000, }, }).before(shell({ id: "inner-docker-runtime", command: [ "set -eu", "mkdir -p /workspace", "docker info >/dev/null", ].join("\n"), user: "root", changeFrequency: changeFrequency.rare, })); ``` The order is: start and check the Docker daemon in the Sandbox, then run this preparation step, and finally run the Agent. When the command exits non-zero, this Attempt is recorded as `errored` in the `sandbox.create` phase, and the Agent never gets a half-prepared Sandbox. Keep fixed content in the image, and for each Attempt copy or import only what must be written to tmpfs or the Docker data directory inside the Sandbox. That way dependencies are installed fewer times, and network changes are less likely to make each run's environment different. With a Docker profile, the results of preparation steps cannot be cached by default, because the default `sandboxState.all` cannot be saved. A step that declares `cache.state: sandboxState.dockerData` can be reused directly next time only when both conditions hold: the step changes only `/var/lib/docker` in the Sandbox, which has stopped being written; and the profile is configured with a separate, pre-allocated, fixed-size storage slot and seed data. Profiles with shared loop or project-quota storage show `Unsupported`, and this step and the steps after it really execute every time. ### Raw privileged DinD On a VM that is thrown away after use or on a dedicated runner, you can choose raw privileged. The outer container runs privileged, so it is not an isolation solution and only suits cases where the machine itself is disposable: ```ts title="experiments/dind-raw.ts" theme={null} import { defineExperiment } from "niceeval"; import { codexAgent } from "niceeval/adapter"; import { dockerSandbox } from "niceeval/sandbox"; export default defineExperiment({ agent: codexAgent(), model: "gpt-5.4", sandbox: dockerSandbox({ source: { type: "dockerfile", context: new URL("../sandbox/", import.meta.url), }, user: "node", dockerAccess: { mode: "dind", isolation: "raw-privileged", storageProfile: "default", }, resources: { dockerDataBytes: 6 * 1024 ** 3, }, }), }); ``` `isolation: "raw-privileged"` must be written explicitly; it means you confirm you accept the risk. On failure, NiceEval never switches to managed mode automatically. `storageProfile` allocates bounded disk space for `/var/lib/docker` in the Sandbox; do not put this path in `tmpfs`. It only handles disk quota and cleanup after the process is force-killed; it does not make raw privileged safe. ### Use OrbStack on macOS For trusted local evals on macOS, you can use OrbStack's Docker. First let OrbStack create the Docker context, then find the actual Unix socket: ```bash theme={null} docker context inspect orbstack --format '{{.Endpoints.docker.Host}}' ``` Remove the leading `unix://` from the output and put the remaining path in `dockerAccess.socketPath`. This is still the Docker socket mode; the Agent can fully control OrbStack's Docker, so it does not suit untrusted tasks. When you need managed rootless DinD, create a full Ubuntu machine in OrbStack instead of installing the host service on macOS: ```bash theme={null} orb create ubuntu niceeval-runner orb -m niceeval-runner ``` Inside the machine, prepare systemd, cgroup v2, and a filesystem that supports project quota the Ubuntu way, and run NiceEval in this machine. Until the Ubuntu installation package is released, this path requires installing the host service yourself and does not work out of the box. Do not use OrbStack's Docker as a managed profile. ### Managed rootless DinD On a shared host, or when the Agent is untrusted, choose managed mode. Compared with raw mode, it adds these protections: * checks before the run that the host profile is deployed as required; * limits each Sandbox's CPU, memory, process count, and disk; * all NiceEval processes share one capacity budget and queue when it is exceeded; * each Sandbox has its own outer network; * the host service reclaims resources after a process is force-killed. For a NixOS host, first deploy and verify the profile following [Configure Managed DinD on NixOS](/docs/tutorials/nixos-managed-dind), then write the Experiment below. The example below assumes the Dockerfile has already copied a fixed runtime archive to `/opt/images/runtime.tar`. `load-fixed-runtime` only changes `/var/lib/docker` in the Sandbox, so it can declare `sandboxState.dockerData`. If a preparation step also writes the workspace, home, tmpfs, mounted directories, or external systems, it cannot declare this. ```ts title="experiments/dind-managed.ts" theme={null} import { defineExperiment } from "niceeval"; import { codexAgent } from "niceeval/adapter"; import { changeFrequency, dockerSandbox, sandboxState, shell, } from "niceeval/sandbox"; const GiB = 1024 ** 3; const MiB = 1024 ** 2; export default defineExperiment({ agent: codexAgent(), model: "gpt-5.4", maxConcurrency: 4, sandbox: dockerSandbox({ source: { type: "dockerfile", context: new URL("../sandbox/", import.meta.url), }, user: "node", dockerAccess: { mode: "dind", isolation: "managed-rootless", profile: "default", }, resources: { cpus: 4, memoryBytes: 6 * GiB, pidsLimit: 2048, dockerDataBytes: 6 * GiB, readOnlyRootfs: true, tmpfs: { "/home/sandbox/workspace": { sizeBytes: 2 * GiB, mode: 0o755, uid: 1000, gid: 1000, executable: true, }, "/home/node": { sizeBytes: 512 * MiB, mode: 0o700, uid: 1000, gid: 1000 }, "/tmp": { sizeBytes: 1024 * MiB, mode: 0o1777 }, "/run": { sizeBytes: 128 * MiB, mode: 0o755 }, }, }, }).before(shell({ id: "load-fixed-runtime", command: "docker load --input /opt/images/runtime.tar", changeFrequency: changeFrequency.rare, cache: { state: sandboxState.dockerData }, })), }); ``` `dockerDataBytes` is the disk cap for Docker data in each Attempt. The host service carves a dedicated space out of the disk quota and mounts it at the Sandbox's `/var/lib/docker`. So Docker images and BuildKit cache use disk and do not count toward the host's `Shmem`; paths that really need to be in memory, such as the workspace and `/tmp`, can still use bounded `tmpfs`. When the profile does not exist, its name is misspelled, the check fails, or capacity is insufficient, the run fails before calling the model and never falls back to raw privileged mode. After host deployment, check the profile first, then run: ```bash theme={null} pnpm exec niceeval docker profile list pnpm exec niceeval docker profile doctor default pnpm exec niceeval exp dind-managed ``` `list` should list `default`. `doctor` starts one DinD container with this profile and actually runs `docker run --rm alpine:3.20 true` inside it. It proves the profile can run nested Docker, but does not check your project's Dockerfile; whether the project image works is confirmed by the run-time `docker info` check. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. ### Set run concurrency for DinD `resources.memoryBytes` limits one Sandbox's memory, and `dockerDataBytes` limits its Docker data. The profile's capacity is shared by all NiceEval processes on the same host; when memory or disk is insufficient, new Attempts queue. Start with a smoke run at low concurrency: ```bash theme={null} pnpm exec niceeval exp dind-managed --max-concurrency 2 ``` Then raise it gradually based on the host's free memory, CPU, and disk speed. The profile's total capacity keeps several Experiments together from overwhelming the host, and `maxConcurrency` limits how many one run executes at once; neither replaces the other. ### Locate DinD creation failures When Sandbox creation fails, the Attempt is recorded as `errored`. View the error text with the Attempt locator the terminal gives you; do not dig through files under `.niceeval/` directly: ```bash theme={null} pnpm exec niceeval show @1ABC234DEF ``` Common errors and how to handle them: | Public error | How to handle it | | - | - | | `DOCKER_HOST must be unset` or `DOCKER_CONTEXT must be unset` | Remove the endpoint/context environment variables from the image so the default endpoint points to `/var/run/docker.sock` | | `dind-image-incompatible: missing ...` | Derive from a pinned `docker:-dind` and add the tools listed in the error | | `permission denied` | At build time, add the Agent user matching `user` to the image's `docker` group | | Docker access compatibility timeout | In the `dockerd.log` tail given by `show @` or `query`, check daemon startup, the socket, and the storage driver | | Leftover Sandboxes reported before startup | First inspect with `pnpm exec niceeval sandbox list --orphans` (read-only), then run `pnpm exec niceeval sandbox prune` after confirming | ## Verify Docker tasks in evals In all three modes, using Docker in an eval is written the same way. The eval below has the Agent repair and start a Compose project, then confirms that containers run normally in the Sandbox: ```ts title="evals/docker-compose.eval.ts" theme={null} import { defineEval } from "niceeval"; import { commandSucceeded } from "niceeval/expect"; export default defineEval({ description: "Agent can repair and start a Docker Compose project", async test(t) { await t.send("Repair the Compose project, then start the services and confirm the health checks pass."); t.check( await t.sandbox.runCommand("docker", ["run", "--rm", "alpine:3.20", "true"]), commandSucceeded(), ); }, }); ``` In socket mode, these commands operate the host Docker you specified; in both DinD modes, they operate the Sandbox's own Docker. `sandbox` can also be written directly in `defineEval({ sandbox: ... })`. When several evals share the same configuration, put it on the Experiment. # Use Eval Groups to Let Related Evals Share a Sandbox Source: https://niceeval.com/docs/tutorials/eval-groups When several groups of evals each need a heavy toolchain, group them with defineEvalGroup: each group shares one Sandbox and runs in sequence, while different groups run in parallel as usual. Your eval set has 20 Rust tasks and 30 Python tasks. Every Rust task needs an environment with cargo and dependencies installed, which takes five minutes to set up. Installing it for every task is too slow; chaining all 50 tasks with `sandboxReuse` is also too slow, since the Python tasks have no reason to wait for the Rust tasks. Eval groups solve this. Use `defineEvalGroup()` to put the 20 Rust tasks into one group: the group shares one Sandbox and runs its evals in ID order, while the Python tasks and other groups run in parallel as usual. | What you get | How it behaves at run time | | - | - | | Pay for shared preparation fewer times | Each group Sandbox is created only once, and group-level preparation runs only once | | Stable order within the group | The group is sorted by eval ID, and only one Attempt runs at a time | | Groups still run in parallel | Different groups and ungrouped evals can run at the same time | | Results can still be compared individually | Each Attempt still has its own assertions, verdict, usage, and file changes | An eval group only schedules the Attempts that really need to run this time. Evals that reuse last results do not enter the group Sandbox; the rules for `--rerun`, `attempts`, and early exit are the same as without groups. ## Choose the right kind of reuse first | Task | How | | - | - | | One or a few groups of compatible evals should share a Sandbox while other groups run in parallel as usual | `defineEvalGroup()` | | All evals the Experiment selects can run in any order, and you only want a few lanes that share Sandboxes | `sandboxReuse: true` | | Every Attempt needs a fresh `$HOME`, `/tmp`, and background processes | Do not reuse Sandboxes; only tune concurrency | The same Experiment cannot both select eval groups and declare `sandboxReuse: true`. Both decide which evals share a Sandbox; when both appear, the run reports `eval-group-sandbox-reuse-conflict` before creating any Sandbox. ## Step 1: Put the group file next to its members Create a directory under `evals/` and put the members and `eval-group.ts` in it. The directory's path is the group ID: ```text theme={null} evals/ └── workflow/ ├── eval-group.ts # Group ID: workflow ├── 01-index/ │ └── eval.ts # Eval ID: workflow/01-index └── 02-query/ └── eval.ts # Eval ID: workflow/02-query ``` NiceEval only recognizes the file name `evals/**/eval-group.ts`. Naming it `*.eval-group.ts` means it is not discovered; putting it at `evals/eval-group.ts` does not work either, because it has no enclosing directory to serve as the group ID. ## Step 2: List the members in the group file Each member default-exports the return value of `defineEval()` or `defineScoreEval()` as usual. The group file imports these return values and lists them in `evals`: ```ts theme={null} // evals/workflow/eval-group.ts import { defineEvalGroup } from "niceeval"; import buildIndex from "./01-index/eval.ts"; import queryIndex from "./02-query/eval.ts"; export default defineEvalGroup({ evals: [buildIndex, queryIndex], onUnavailable: "stop-group", }); ``` When writing `evals`: * It cannot be empty, and only accepts imported return values. Eval IDs, directory prefixes, `glob`, `tag`, and `selector` are not allowed. * NiceEval does not collect files from the same directory automatically. Adding or removing members always shows up in the diff of `eval-group.ts`. * Members must be either all `defineEval()` or all `defineScoreEval()`. When they are mixed, the run reports an error before planning Sandboxes and lists the eval IDs of both kinds, so you can split them into two groups. * An eval belongs to at most one group, and cannot be listed twice in the same group. Array order does not decide execution order. Within a group, evals always run sorted by eval ID, and moving array positions does not change scheduling. When you need a dependency such as "build first, then query", write both steps in the same eval. Ordering members by business sequence is not supported yet. `onUnavailable` is required. It decides what happens when the group Sandbox cannot be created, reset, or prepared: * `"stop-group"`: the rest of this group's evals are no longer dispatched. * `"replace-sandbox"`: discard the current Sandbox and try creating a new one for the next Attempt. Without it, loading `eval-group.ts` reports an error. Whether to spend more money rebuilding or to stop after a failure is your decision; NiceEval does not guess for you. Which evals run is still decided by the Experiment and the command line. When only `workflow/02-query` is selected, `workflow/01-index` is not pulled into this run automatically. ## Step 3: Have the Experiment provide the Sandbox In most projects, the Experiment chooses the Provider and `template`, and the eval group only decides "which evals share a Sandbox": ```ts theme={null} // experiments/codex.ts import { defineExperiment } from "niceeval"; import { codexAgent } from "niceeval/adapter"; import { e2bSandbox } from "niceeval/sandbox"; export default defineExperiment({ evals: ["workflow/"], agent: codexAgent(), sandbox: e2bSandbox({ template: "codex", lifetimeMs: 60 * 60_000, }), maxConcurrency: 4, }); ``` Do not write `sandboxReuse: true` here. `maxConcurrency: 4` is the cap for the whole Experiment and does not make the same group run four at once; the remaining concurrency slots go to other groups and ungrouped evals. Eval groups only work with Agents that run in a Sandbox and Providers that support reuse. An Agent without a Sandbox cannot run eval groups. ## Step 4: Confirm the grouping before running First look at the plan with `--dry`; it creates no Sandbox and calls no model: ```bash theme={null} pnpm exec niceeval exp codex --dry ``` Confirm that both member evals appear in the plan, then check whether they belong to the same group. In the machine-readable plan, each item carries `evalGroupId`: ```bash theme={null} pnpm --silent exec niceeval exp codex --dry --json ``` Both evals' `evalGroupId` should be `workflow`. When the field is missing, first check that the group file's name and location are `evals/workflow/eval-group.ts`. After confirming, run: ```bash theme={null} pnpm exec niceeval exp codex ``` Within the group, evals queue by eval ID. When `attempts` is greater than 1, the Attempts of one eval run back to back before the next eval's turn. Positions that do not need to run because of early exit or result reuse are skipped directly. This is only a stable queueing rule; it cannot be used to pass data between evals. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. ## Put shared preparation on the eval group Sandbox preparation can be written in three places: the Experiment, the eval group, and the eval. Only one of the three can choose `template`. A common split: | Written on | What fits there | How often it runs | | - | - | - | | The Experiment's `sandbox` | A Provider `template` that varies with the Agent or model | Once per group Sandbox | | The eval group's `sandbox` + `.before()` | Toolchains, shared repositories, and build cache warm-up used by the whole group | Reuses the last preparation result when content has not changed | | A member's own `sandbox` + `.before()` | Starting code, dependencies, and public materials that belong only to this eval | Every Attempt that really runs | Write the eval group's preparation steps as `sandboxLayer()`: ```ts theme={null} import { defineEvalGroup } from "niceeval"; import { changeFrequency, sandboxLayer, shell, } from "niceeval/sandbox"; import buildIndex from "./01-index/eval.ts"; import queryIndex from "./02-query/eval.ts"; export default defineEvalGroup({ evals: [buildIndex, queryIndex], sandbox: sandboxLayer().before(shell({ id: "group-toolchain", command: "npm install --global tsx@4.22.4", changeFrequency: changeFrequency.rare, })), onUnavailable: "stop-group", }); ``` Members cannot choose `template`, but they can add their own `.before()` / `.after()` commands. The preparation steps of the Experiment, eval group, members, and Agent are placed in one order: dependencies first, then ascending `changeFrequency`. So a rarely changing step in the eval group can come before a frequently changing step in the Experiment. Lifecycle callbacks and Agent Context within a group can read `ctx.evalGroup.id` and `ctx.evalGroup.definitionHash`. The same function may also serve ungrouped evals, so `evalGroup` is an optional field. When you need to isolate caches or service names by group outside `workdir`, first check that the field exists, then build names from the group ID. ## Do not rely on group order to pass results After each Attempt, NiceEval restores `workdir` to its state when shared preparation finished. Files an earlier eval wrote into `workdir` are not visible to a later one; `$HOME`, `/tmp`, global installs, and background processes may stay behind. When you cannot accept those leftovers, do not reuse Sandboxes. An earlier eval may not even run. Result reuse, command-line filtering, an exhausted budget, and interruption can all keep a member out of the Sandbox this time. When a later step must read a file from an earlier step, write both steps in the same eval. Only put evals in the same group when what they share already lives outside `workdir` and each eval can reach a verdict on its own. ## Common errors | Error | Fix | | - | - | | `eval-group-member-unresolved` | Import the object the member file originally default-exports; do not reconstruct the object or write a string ID | | `eval-group-member-overlap` | Put each eval in only one group, and list it only once | | `eval-group-member-layer` | Remove the Provider template from the member and keep only `.before()` / `.after()` commands | | Loading reports a missing `onUnavailable` | Write `"stop-group"` or `"replace-sandbox"` explicitly | | `eval-group-sandbox-reuse-conflict` | Remove `sandboxReuse: true` from the Experiment | | `eval-group-direct-agent` | Switch to an Agent that runs in a Sandbox, or stop this Experiment from selecting grouped evals | | `sandbox.reuse-unavailable` | Switch to a Provider that supports Sandbox reuse | | `eval-group-incompatible` | Make the members' Sandbox configuration consistent, or split them into several groups by `template` | ## Read next * [Reuse Complete Eval Conditions with Plugins](/docs/tutorials/plugins): write a full set of conditions needed by groups, experiments, or members once, and attach it where needed. * [Reuse Sandboxes](/docs/tutorials/sandbox-reuse): when the whole batch suits sharing Sandboxes, the lifecycle and leftover boundaries of `sandboxReuse: true`. * [Tune Concurrency](/docs/tutorials/concurrency): how eval groups work with the global and Experiment concurrency caps. * [Sandbox Provider Configuration](/docs/tutorials/sandbox-providers): choose a `template`, set lifetimes, and write preparation steps. * [Rerun and Reuse](/docs/tutorials/rerun-and-cache): which Attempts enter the group queue this time. # Score by Completion: Pass/Fail and Scored Evals Source: https://niceeval.com/docs/tutorials/evaluation-kinds When a task has only two outcomes, done or not done, use defineEval and read the pass rate; when it has partial progress, use defineScoreEval and accumulate points for completed steps. You ask an Agent to install an open source project and get its health check passing, in five steps. It completes three and gets stuck on the fourth. Looking only at pass or fail, this run is a "failure", no different from doing nothing at all. But what you want to know is how far the Agent is from finishing, and whether a prompt change moved it from three steps to four or left it where it was. So NiceEval has two kinds of evals: * `defineEval` is pass/fail. It answers "was it done this time", and you read the pass rate. It suits tasks that are only meaningful when everything holds, such as tests that must pass or keys that must not leak. * `defineScoreEval` is scored. It answers "how much was done", and you read the accumulated score. It suits tasks with partial progress, such as step-by-step installation or several quality requirements. An Experiment or eval group can contain only one kind. `niceeval check`, `--dry`, and normal runs all reject mixing them before starting the Agent or Sandbox; when you need both readings, split them into two Experiments. ## Use `defineEval` for what must be done ```ts theme={null} import { defineEval } from "niceeval"; import { commandSucceeded, includes } from "niceeval/expect"; export default defineEval({ async test(t) { const turn = await t.send("Fix the tests and explain the cause."); t.check(turn.message, includes("cause")).label("Explains the failure cause"); const tests = await t.sandbox.runCommand("pnpm", ["test"]); await t.check(tests, commandSucceeded()).label("Tests pass").orStop(); }, }); ``` A Boolean `matched` goes into the Verdict; `mismatched` makes the final Verdict `failed`, but does not stop other Assertions from being registered and settled. When later code must depend on this result, `await handle.orStop()`. ## Continuous quality thresholds Matches such as `similarity(...)` produce a measurement in `[0, 1]`. It computes character-level Levenshtein edit similarity and does not judge meaning. A pass/fail eval sets a quality gate on the handle with `.gate(minimum)`: ```ts theme={null} t.check(turn.message, similarity(expected)) .gate(0.8) .label("Answer is close to the expected content"); ``` When the value is below the threshold formed on the Match, this requirement fails. When you only want to leave a note that does not affect the verdict, use `t.diagnostic(...)`; do not register a measurement nobody consumes. ## Three steps done should still earn three points Step-by-step tasks suit `defineScoreEval`. An Assertion only saves its evaluation by default and does not score; only `.score(n)` makes it contribute points. A Boolean matched contributes `n` and mismatched contributes `0`; a measurement `m` contributes `m * n`. ```ts theme={null} import { defineScoreEval } from "niceeval"; import { commandSucceeded, includes } from "niceeval/expect"; export default defineScoreEval({ description: "Install and start DB-GPT", async test(t) { const turn = await t.send("Install DB-GPT and get the health check passing."); t.sandbox.fileChanged("db-gpt/.env").score(1).label("Configure the runtime environment"); const health = await t.sandbox.runCommand("curl", ["-s", "localhost:5670/health"]); t.check(health, commandSucceeded()).score(1).label("Health check reachable"); t.check(health.stdout, includes("ok")).score(1).label("Health check content correct"); t.check(turn.message, includes("verify")).score(2).label("Reply explains how it was verified"); t.score(1).label("Concise code"); }, }); ``` `t.score(n)` registers a contribution directly. `n` must be finite and not less than zero, and the returned handle can only configure `key` and `label`. A Judge or other measurement can call `.gate(minimum)` and `.score(n)` on the same handle in either order, and the evaluator runs only once. When `test` returns normally, NiceEval computes the final score automatically. Having no scoring items is also a valid result and yields a formal `score: 0`; it means the eval successfully produced a zero, not an execution failure or missing evidence. ## Results of the two kinds | Kind | Success state | Explicit requirement fails | Evidence unavailable | Execution or evaluator error | | - | - | - | - | - | | `defineEval` | `passed` | `failed` | `errored` | `errored` | | `defineScoreEval` | `passed` with a complete score; can enter the success ranking | `failed`; keeps the earned score, does not enter the success ranking | `errored` and not rankable when an item with a configured score or control is unavailable | `errored`; keeps only an auditable partial score | A complete score in a scored eval can be 0. `passed + complete 0` means the eval successfully produced a zero, which is not an execution failure or missing evidence; `failed + complete` must still show as a failure and cannot be rewritten as success just because the value is complete. ## The Judge is a measurement too `defineJudge` declares a managed Judge Match. The author assembles the values needed for the judgment into material with semantic fields. The Judge can be passed straight to `t.judge` without registering it on the eval. The model defaults to the project's `judgeRuntime`; to switch only the model for one eval, set `judge: "judge-model"`. ```ts theme={null} const explainsMotive = defineJudge({ name: "explains-motive", rubric: "Given the change, rate whether the explanation states the motive for the change.", }); const explainsRisk = defineJudge({ name: "explains-risk", rubric: "Given the change, rate whether the explanation identifies specific risks.", }); export default defineEval({ async test(t) { const change = "Add the model version to the cache key"; const turn = await t.send(`Explain the motive and risks of this change: ${change}`); const material = { change, explanation: turn.message }; t.judge(material, explainsMotive).gate(0.8).label("Motive for the change"); t.judge(material, explainsRisk).gate(0.8).label("Specific risks"); }, }); ``` Requirements that can fail, be weighted, or show reasons independently should each register their own Judge. A scored eval can combine `.gate(minimum)` and `.score(points)` on the same Judge; the measurement times points gives the contribution, and the gate decides the Verdict. The same Judge runs only once. ## Accept code tasks with real commands ```ts theme={null} await t.check( await t.sandbox.runCommand("pnpm", ["test"]), commandSucceeded(), ).label("Project tests pass"); ``` When the scoring standard is itself a file, read hidden tests, reference implementations, or test scripts with `loadText`. Changing the file triggers a rerun of the matching evals; see [Hidden Test Scoring](/docs/tutorials/criteria-files). ## Practical advice * Register each condition that must hold as a Boolean Assertion, so one Attempt collects complete failure information. * Split open-ended standards that can fail independently, be weighted independently, or need separate diagnosis into several Judges. * Use one Judge's measurement times a single weight only for an indivisible overall quality dimension. * When later steps depend on a result, `await handle.orStop()`. * Use `defineScoreEval` when a task has meaningful partial completion; it wraps up automatically when `test` returns normally. * Use the Judge for open-ended meaning; prefer deterministic Matches for files, commands, and structured output that can be checked exactly. # Start Shared Experiment Services Source: https://niceeval.com/docs/tutorials/experiment-services Use an Experiment's setup / teardown to start and stop tunnels, mock servers, or leases shared by the whole experiment, and hand run-time addresses to the Agent and Sandbox without invalidating existing results. You are evaluating a Coding Agent connected to a memory service. The memory service runs on an internal network, so before each eval run you open a tunnel, get a temporary address, and tell the Agent the address. Opening the tunnel once is enough for dozens of Attempts to share; after everything finishes, you must remember to close it. Resources like this, "one per experiment, shared by all Attempts", go in the Experiment's `setup` and `teardown`. ## Write a setup / teardown pair ```ts theme={null} // experiments/memory/codex.ts import { defineExperiment } from "niceeval"; import { nowledgeAgent, nowledgeTunnel } from "../../agents/nowledge.ts"; // Coordinates from setup live in a module variable, so teardown and the Agent can read them let tunnel: { url: string; apiKey: string; stop(): Promise } | undefined; export default defineExperiment({ agent: nowledgeAgent(() => ({ url: tunnel!.url, apiKey: tunnel!.apiKey })), evals: ["memory/"], async setup(ctx) { ctx.progress({ message: "starting nowledge tunnel" }); tunnel = await nowledgeTunnel({ signal: ctx.signal }); }, async teardown(ctx) { ctx.progress({ message: "stopping nowledge tunnel" }); await tunnel?.stop(); }, }); ``` Both callbacks run on your own machine, each at most once: * `setup` runs before the first Attempt of this experiment that really executes. * `teardown` runs after all Attempts finish, and also runs when the run is interrupted. * Once `setup` has started, `teardown` always runs, even if `setup` throws. So `teardown` must guard against variables that were never assigned; the example above uses `tunnel?.stop()`. * When all last results can be reused and not a single Attempt needs to really execute this time, neither runs. While `setup` runs, the terminal's `ACTIVE` area shows a line `experiment setup · `, and messages from `ctx.progress(...)` update at the end of that line. During this time the experiment's Attempts count as queued; nothing is stuck. ## What happens when setup fails When `setup` throws, every Attempt of this experiment is recorded as `errored`, with error code `experiment-setup-failed`. Other experiments in the same batch run as usual. An environment that did not start is never counted as a pass, and does not drag other experiments down. ## Guarantee release first in teardown Releasing resources must happen; observation such as health checks and metric reporting is only best effort. Wrap them in `try/finally` so an observation error cannot block release, and skip observation directly when the run is interrupted: ```ts theme={null} async teardown(ctx) { try { if (!ctx.signal.aborted) { await tunnel?.probe({ timeoutMs: 10_000 }).catch(() => {}); } } finally { await tunnel?.stop(); } }, ``` ## Do not put run-time addresses in flags The tunnel gets a new URL every time it restarts. `flags` are experiment conditions; when a value changes, results already finished can no longer be reused. Putting a temporary address in `flags` means every tunnel restart forces the whole batch to rerun. Keep the address in a module variable and let the Agent factory or Sandbox callback read it: ```ts theme={null} let endpoint: string | undefined; export default defineExperiment({ agent: nowledgeAgent(() => ({ url: endpoint! })), sandbox: e2bSandbox({ template: "niceeval-agents" }) .before(async (sandbox) => { await sandbox.writeText(".nowledge/env", `NMEM_URL=${endpoint!}\n`); }), async setup(ctx) { endpoint = (await nowledgeTunnel({ signal: ctx.signal })).url; }, }); ``` When you run again with a new URL, finished results are reused as usual and only what is missing runs: ```text theme={null} $ pnpm exec niceeval exp compare/codex--nowledge ╭─ PLAN ──────────────────────────────────────────╮ │ 36 attempts · 36 evals × 1 configs │ │ 24 of 36 reuse from cache · 12 to run │ ╰─────────────────────────────────────────────────╯ ``` To decide where a value belongs, ask: is it an experiment condition you want to compare? * Yes, such as the memory service version `0.10.39`, where a different version may really behave differently: put it in `flags`, and rerun when it changes. * No, it is just this run's connection coordinates: keep it in `setup` and a module variable. * An annotation only used to group reports: write it in `labels`. ## Prepare the environment in the Sandbox `setup` / `teardown` manage services on your machine. Preparation that must happen in the Sandbox before the Agent runs, such as writing configuration, installing binaries, or loading previously saved state, attaches to the `sandbox`'s `.before()`. It runs once for each Attempt that really executes, always after `setup`, so it can read the variables `setup` assigned: ```ts theme={null} // experiments/compare/claude--nowledge.ts import { defineExperiment } from "niceeval"; import { e2bSandbox } from "niceeval/sandbox"; import { nowledgeAgent, nowledgeTunnel } from "../../agents/nowledge.ts"; import { loadMemoryState, saveMemoryState } from "../shared/memory-state.ts"; let tunnel: { url: string; apiKey: string; stop(): Promise } | undefined; export default defineExperiment({ agent: nowledgeAgent(() => ({ url: tunnel!.url, apiKey: tunnel!.apiKey })), evals: ["memory/"], maxConcurrency: 1, sharedState: { key: "nowledge/claude/cohort-a" }, sandbox: e2bSandbox({ template: "niceeval-agents" }) .before(async (sandbox, ctx) => { // Write the host-side coordinates into the Sandbox await sandbox.writeText( ".nowledge/config.json", JSON.stringify({ url: tunnel!.url, apiKey: tunnel!.apiKey }), ); await loadMemoryState(sandbox, ctx.experimentId); ctx.onCleanup(async (sandbox) => { await saveMemoryState(sandbox, ctx.experimentId); }); }), async setup(ctx) { tunnel = await nowledgeTunnel({ signal: ctx.signal }); }, async teardown() { await tunnel?.stop(); }, }); ``` Read from top to bottom, this file is the complete run description: services with one copy per experiment are in `setup` / `teardown`, and per-Sandbox preparation and cleanup are in `.before()` and `ctx.onCleanup`. Fixed installations such as Agent CLIs and system packages are best baked into an image or template first. NiceEval reuses unchanged preparation steps and does not reinstall just because an eval changed. See [Prebuilt Sandbox Environments · Continue building from the official baseline](/docs/tutorials/prebuilt-sandbox#continue-building-from-the-official-baseline). ## Save state across Attempts In the example above, each Attempt loads memory at the start and saves it back at the end, so memory accumulates eval by eval. This requires Attempts to run strictly one after another: * `maxConcurrency: 1` makes the Attempts in this run serial. * `sharedState.key` prevents you from running the same experiment in another terminal at the same time, with both sides interleaving reads and writes of the same state. `sharedState` only guarantees that one run holds the key at a time. Storage itself, atomic write-back, and recovery after an abnormal interruption are still your responsibility. Do not put secrets in the key. ## Share the same start/stop code across experiments In comparison experiments, several experiments often target the same kind of infrastructure, for example Claude Code and Codex each connected to the same memory product. Write the start/stop as a factory function and call it once in each experiment file: the code is written once, while instances and coordinates stay separate: ```ts theme={null} // experiments/shared/nowledge.ts import type { ExperimentHookContext } from "niceeval"; import type { SandboxCommand } from "niceeval/sandbox"; export function nowledgeLifecycle() { let instance: string | undefined; let env: { url: string; apiKey: string } | undefined; return { /** The Agent factory reads connection info through this; it has a value only after setup */ endpoint: () => env!, async setup(ctx: ExperimentHookContext) { instance = `exp-${ctx.experimentId.replace(/[^A-Za-z0-9]+/g, "-")}`; ctx.progress({ message: `[nowledge] activating ${instance}` }); await memctl("up", instance); env = await readInstanceEnv(instance); }, async teardown() { if (!instance) return; // setup failed before starting an instance, so there is nothing to clean up await memctl("down", instance); }, /** Once per Attempt: write the coordinates into the Sandbox */ sandboxBefore(): SandboxCommand { return async (sandbox) => { await sandbox.writeText(".nowledge/env", `NMEM_URL=${env!.url}\nNMEM_API_KEY=${env!.apiKey}\n`); }; }, }; } ``` Wire it up in the experiment file with four lines: ```ts theme={null} // experiments/compare/codex--nowledge.ts const nowledge = nowledgeLifecycle(); export default defineExperiment({ agent: codexAgent(nowledgeCodexConfig(nowledge.endpoint)), sandbox: e2bSandbox({ template: CODEX_TEMPLATE }).before(nowledge.sandboxBefore()), setup: nowledge.setup, teardown: nowledge.teardown, maxConcurrency: 1, }); ``` Keep two rules when writing it this way, so several experiments running concurrently do not overwrite each other: * When the factory function is called, it only creates a closure, with no I/O and no configuration reads. `niceeval exp` imports experiment files during discovery, and a throw at that point drags down unrelated experiments in the same batch. Put every operation that can fail in `setup`. * Run-time coordinates live in the factory closure, not in a module-level singleton. Two parallel experiments each hold their own copy and do not overwrite each other. When running several copies of the service is too expensive and a batch of experiments must share one instance, use reference counting: the first to arrive starts it, and the last to leave closes it. ```ts theme={null} // experiments/shared/nowledge-shared.ts let refs = 0; let starting: Promise | undefined; let service: { url: string; stop(): Promise } | undefined; export const sharedNowledge = { async setup() { refs += 1; starting ??= startNowledge().then((s) => { service = s; }); await starting; // Concurrent experiments wait for the same start }, async teardown() { refs -= 1; if (refs === 0) { await service?.stop(); // service has no value when start failed service = undefined; starting = undefined; } }, }; ``` Because `teardown` always runs in pairs with any `setup` that started, the count always returns to 0 and never leaks. ## Services that live across several runs A service started by `setup` does not outlive this one `niceeval exp`. For a service that should be started ahead of time and used across several runs, start and stop it with an external tool such as `docker compose`, then pass the address to the Adapter through an environment variable. For the full parameters of `setup` and `teardown`, see [Experiment](/docs/explanation/experiment). For the order, caching, and cleanup of Sandbox preparation steps, see [Sandbox · Prepare the Sandbox in order](/docs/tutorials/sandbox-providers#prepare-the-sandbox-in-order). # Prepare Tasks for a Coding Agent: Workspace, Prompt, and Acceptance Source: https://niceeval.com/docs/tutorials/fixtures Put a real project into the Sandbox in an eval, give the Coding Agent a task, then check whether it did it right with file contents, command results, diffs, and the Judge. When evaluating a Coding Agent, looking only at what it replies is not enough. You give it a real project, let it read and write files and run commands, then check whether what it changed is right. All of this is written in an ordinary `.eval.ts`, in three steps: 1. Put the starting project into the Sandbox; this is the Fixture. 2. Give the Agent a specific task. 3. Accept the work with file contents, command results, and diffs, and hand open-ended quality to the Judge. For a runnable reference, see [coding-agent-skill](https://github.com/CorrectRoadH/coding-agent-skill) (a separate repository). ## Recommended directory layout Keep the starting project and the evals apart. The Agent can only see what you upload into the Sandbox; eval code and verification materials stay outside: ```text theme={null} evals/ ├─ api-validation.eval.ts ├─ config-schema.eval.ts └─ ponytail-safe-path.eval.ts workspaces/ └─ ts-starter/ ├─ package.json ├─ tsconfig.json └─ src/ skills/ └─ zod.md experiments/ ├─ baseline.ts └─ with-skill.ts ``` `workspaces/` holds the starting projects the Agent can see. The `.eval.ts` decides when to upload, what task to send, and how to check the result. `experiments/` chooses the Agent and model, and whether to load a Skill or plugin. ## Put the starting project into the Sandbox For the Agent to work in a real project, the first step is uploading the project into the Sandbox: ```ts theme={null} import { defineEval } from "niceeval"; export default defineEval({ description: "Add request body validation to an Express route", async test(t) { await t.sandbox.uploadDirectory("../workspaces/ts-starter"); await t.send("Implement the POST /users route in src/routes/users.ts.").then((turn) => turn.succeeded()); t.sandbox.fileChanged("src/routes/users.ts"); }, }); ``` The path is relative to the eval file. Uploaded files go under the Sandbox's working directory, and the Agent can see them from the start. When only one or two files are missing, write them directly with `t.sandbox.writeText()`, which suits small tasks or safety check scripts. When `fileChanged` fails, first use `pnpm exec niceeval show @ --diff` to see which files the Agent actually changed. ## Write the task prompt The prompt should read like a real ticket: state the goal and constraints clearly, but do not give away the verification answer. ```ts theme={null} await t .send( `Implement environment variable validation in src/config/env.ts. Requirements: - Define EnvSchema with Zod - Convert PORT to a number - DATABASE_URL must be a valid URL - JWT_SECRET must be at least 32 characters - Export the env object and the Env type`, ) .then((turn) => turn.succeeded()); ``` Quality requirements not written into the request, such as "does it prevent path traversal" or "does it reuse existing tools", are checked later during verification. That is the only way to measure whether a Skill or plugin really made the Agent add those considerations on its own. ## Verify files and code After the Agent runs, read the files it changed and check that the key code is in place: ```ts theme={null} import { not, pattern } from "niceeval/expect"; const code = await t.sandbox.readText("src/config/env.ts"); t.check(code, pattern(/z\.object\s*\(/)); t.check(code, pattern(/EnvSchema\.parse\s*\(\s*process\.env/)); t.check(code, not(pattern(/process\.env\.\w+\s*\?\?/))); t.sandbox.fileChanged("src/config/env.ts"); ``` `t.sandbox.fileChanged()` only looks at the Agent's changes during `t.send()` to decide whether it changed the target file. Files you uploaded or wrote yourself do not count. For the recording rules, see [Sandbox Agent · Make file changes recordable](/docs/tutorials/sandbox-agent#make-file-changes-recordable). ## Run project tests or check scripts Text matching only shows what the code looks like; running it shows whether it is right. For Coding Agent tasks, real commands are usually more reliable. The example below writes a check script after the Agent finishes and runs it: ```ts theme={null} await t.sandbox.writeText( "_test_traversal.py", [ "import sys, os", "sys.path.insert(0, '.')", "from uploads import safe_upload_path", "base = '/var/uploads'", "try:", " p = safe_upload_path(base, '../../etc/passwd')", " assert os.path.commonpath([base, os.path.abspath(p)]) == base", "except (ValueError, PermissionError, AssertionError):", " pass", "print('ok')", ].join("\n"), ); const result = await t.sandbox.runCommand("python3", ["_test_traversal.py"]); t.check(result.stdout.trim(), pattern(/ok/)); ``` You can also run the project's own test, lint, or build scripts directly: ```ts theme={null} import { commandSucceeded } from "niceeval/expect"; const result = await t.sandbox.runCommand("npm", ["test"]); t.check(result, commandSucceeded()); ``` When `commandSucceeded()` fails, use `pnpm exec niceeval show @` to see the command's exit code and output. ## Use Experiments as control groups To know whether a Skill or plugin helps, run the same tasks with and without it. The conditions go in the Experiment: it chooses the Agent and fixes the run configuration. The first command-line argument selects the Experiment, and later arguments filter evals by ID prefix. Do not put Agent names, plugin names, or URLs in command-line arguments. ```bash theme={null} pnpm exec niceeval exp compare pnpm exec niceeval exp compare api-validation ``` A typical A/B comparison: * `experiments/baseline.ts`: the Agent with no extra configuration. * `experiments/with-skill.ts`: the same Agent, writing `CLAUDE.md` with a lifecycle Hook or a [Plugin](/docs/tutorials/plugins). * Both groups use the same evals, the same model, the same `attempts`, and the same budget. After both groups run, compare them in the terminal with `pnpm exec niceeval show --experiment baseline --experiment with-skill`, or view them in the browser with `pnpm exec niceeval view`. When the two groups score almost the same, first confirm that `CLAUDE.md` really exists in the `with-skill` Sandbox. ## When to use a Fixture * The Agent needs to modify real files. * You need to run the project's tests, build, or lint. * You need to compare code changes. * You are evaluating a Coding Agent such as Claude Code or Codex. * You want to compare whether a Skill, plugin, or Hook improves results on real tasks. If the system under test is an ordinary conversational application that needs no files or commands, connecting it with a conversational Adapter is lighter; see [Connect Your Agent](/docs/tutorials/connect-your-agent). # Cut Losses When Credentials Expire or a Service Goes Down Source: https://niceeval.com/docs/tutorials/handle-execution-failures Learn which failures NiceEval retries automatically; when you find faults that are bound to fail, such as expired credentials or a disconnected shared service, stop the remaining Attempts and, after fixing them, rerun only what did not finish. A batch of 200 evals reaches the 30th, and the API key expires. The remaining 170 are bound to fail, yet each one would still create a Sandbox, call the model, and consume budget. NiceEval handles two cases: * For transient failures such as rate limits and network blips, as long as it can confirm the Agent has not received the request yet, NiceEval retries automatically, and you do not need to do anything. * For faults such as expired credentials or a disconnected shared service, retrying does not help. You can declare in code which scope it affects, and NiceEval stops dispatching the remaining related Attempts. After the fix, rerun only what did not finish; results already reached are kept as usual. ## First tell a run error from a wrong answer Before fixing anything, check which kind the failed Attempt is: * `errored`: the run itself did not complete and produced no result that can be scored. For example, the target service did not start, the protocol does not match, credentials are invalid, a dependency is missing, or the Adapter threw. Fix the run environment and rerun. * `failed`: the Attempt completed, but some business assertion was not met. It may really expose a problem in the system under test, or it may mean a criterion is too strict or written wrong. First look at the same Attempt's input, output, assertions, and evidence, then decide whether to change the system under test or the eval. Do not attribute a real business failure to infrastructure, and do not change criteria just to turn everything green. A scored eval finishing only means scoring is over. How well it did depends on the score and each assertion; a low-score result does not count as full marks just because it finished. To see an Attempt's error text, copy the locator the terminal gives you: ```sh theme={null} pnpm exec niceeval show @ ``` ## Understand automatic retries Automatic retries need no configuration. While waiting to retry, the Attempt's progress line shows something like: ```text theme={null} turn retry 2/4 (rate_limit) — waiting 8s ``` After a successful retry, scoring and the conversation record keep only the successful Turn. When retries run out, the Attempt becomes `errored`, and the error contains a summary: ```text theme={null} retries exhausted (4 attempts, rate_limit) ``` Without this summary, NiceEval did not retry the error. Requests the Agent has already received, or that cannot be confirmed as not received, are never retried automatically, to avoid repeating operations with side effects. ## Declare the affected scope when you find a fault When you check a shared service or Fixture yourself, you can throw an error that carries the affected scope: * `ExperimentFatalError` stops the Attempts of the same experiment that have not started. It suits shared services, shared credentials, and experiment-level configuration faults. * `EvalFatalError` stops only the current eval's Attempts that have not started. It suits a Fixture that is deterministically missing, or prerequisite resources dedicated to that eval. The eval below stops its own remaining Attempts when the Fixture is missing: ```ts theme={null} // evals/coding/fix-button.eval.ts import { defineEval, EvalFatalError } from "niceeval"; export default defineEval({ async test(t) { const fixture = await t.sandbox.runCommand("test", ["-f", "src/Button.tsx"]); if (fixture.exitCode !== 0) { throw new EvalFatalError( "Fixture is missing src/Button.tsx. Run pnpm fixtures:sync first, then rerun the eval.", ); } await t.send("Fix the Button keyboard focus issue."); }, }); ``` When a shared service is unreachable, put the check in the `test(t)` callback of a real eval. First replace `serviceHealthUrl` with the service's health check address, then use `ExperimentFatalError` to stop the remaining Attempts of the same experiment: ```ts theme={null} // evals/coding/shared-service-health.eval.ts import { defineEval, ExperimentFatalError } from "niceeval"; const serviceHealthUrl = "https://service.example.test/health"; export default defineEval({ async test(t) { const health = await t.sandbox.runCommand("curl", ["-fsS", serviceHealthUrl]); if (health.exitCode !== 0) { throw new ExperimentFatalError( "The shared service is unreachable. Check the service and tunnel, then rerun the experiment.", { cause: health.stderr }, ); } }, }); ``` The error message appears in the terminal and in the run record. State what happened and how to fix it next. ## Recognize a shared service that disconnects mid-run Some shared services are fine when the experiment starts but disconnect during the run. Such failures are usually thrown by an SDK, CLI, or network library. You can recognize your own service address with the experiment's `classifyFailure`: ```ts theme={null} // experiments/compare.ts import { defineExperiment } from "niceeval"; const serviceHost = "memory.internal.example"; export default defineExperiment({ // agent and sandbox configuration omitted classifyFailure({ text }) { const isOurService = text.includes(serviceHost); const isConnectionFailure = /ECONNREFUSED|ENOTFOUND|connection refused/i.test(text); if (isOurService && isConnectionFailure) { return { retryable: false, scope: "experiment", reason: "shared_service_unavailable", }; } return undefined; }, }); ``` Only recognize services whose affected scope you are sure of. Do not treat every `ECONNREFUSED` as an experiment-level fault, because the Agent may hit the same error when visiting other sites. ## After fixing, rerun what did not finish Once loss-cutting takes effect, Attempts in that scope that have not started are no longer dispatched and are recorded as `unstarted`, and the run status shows `incomplete`. Attempts already running finish and record their results honestly; those that triggered the fault are recorded as `errored`. First look at this run's results and confirm that the stopped scope matches what you expected. The Run ID is in the terminal's `NEXT` panel: ```sh theme={null} pnpm exec niceeval show --run ``` You can also view it in the browser with `pnpm exec niceeval view --run `. To see an Attempt's raw error and retry summary, copy its locator: ```sh theme={null} pnpm exec niceeval show @ --execution ``` After fixing the service, credentials, or Fixture, run the original command again: ```sh theme={null} pnpm exec niceeval exp compare evals/coding ``` `errored` and `unstarted` Attempts run again, and passed results are reused as usual. Add `--dry` before running to confirm which will rerun; for the rules, see [Rerun Only the Evals Affected by a Change](/docs/tutorials/rerun-and-cache). If a custom Adapter can clearly recognize a protocol signal meaning "the request was rejected and not yet accepted", it can provide `classifySendFailure` to mark such failures as retryable. For field types and boundaries, see the [`defineSandboxAgent` reference](/docs/reference/define-agent#definesandboxagent). # Install the CLI for a Custom Sandbox Agent Source: https://niceeval.com/docs/tutorials/install-custom-sandbox-agent Use ensure and installer to check, install, and recheck the exact version of a custom coding-agent CLI. You are evaluating a Coding Agent CLI that the built-in adapters do not support yet, or you need to pin a specific version. Installing it into the image by hand every time is error-prone: when the version silently upgrades, two eval runs are no longer comparing the same Agent. Instead, declare the CLI's name, version, and installation method on a custom Sandbox Agent. Before each run, NiceEval checks the CLI in the Sandbox; when the version is wrong, it installs the version you specified, checks again after installing, and starts the eval only when everything is correct. ## Declare the CLI's version and installation method First spell out on the Agent which CLI, which version, and how to install it. For a Node.js CLI, use `createNpmCliInstaller`. The code below connects the npm package `my-agent@1.0.0` to a custom Adapter: ```ts theme={null} // agents/my-agent.ts import { createNpmCliInstaller, defineSandboxAgent, agentBin, responsesEvidenceCoverage, turnFromResponses, type ResponseLike, } from "niceeval/adapter"; const { ensure, installer } = createNpmCliInstaller({ identity: { agent: "my-agent", version: "1.0.0", revision: "1", }, packageName: "my-agent", bin: "my-agent", progress: { checking: "Checking my-agent CLI 1.0.0", installing: "Installing my-agent CLI 1.0.0", ready: "my-agent CLI 1.0.0 is ready", }, }); export default defineSandboxAgent({ name: "my-agent", evidenceCoverage: responsesEvidenceCoverage, ensure, installers: [installer], async setup(sandbox) { await sandbox.writeText(".my-agent/config.json", JSON.stringify({ telemetry: true })); }, async send(input, ctx) { const result = await ctx.sandbox.runCommand("sh", [ "-lc", `exec ${agentBin("my-agent")} run --json "$1"`, "my-agent", input.text, ]); if (result.exitCode !== 0) { throw new Error(`my-agent exit code ${result.exitCode}: ${result.stderr}`); } return turnFromResponses(JSON.parse(result.stdout) as ResponseLike); }, }); ``` This example assumes `my-agent run --json` outputs a JSON object compatible with OpenAI Responses. `turnFromResponses` converts its `output` and usage into a NiceEval `Turn`. When the CLI outputs another format, replace this line with your own mapping to `events`, `status`, and `usage`; see [Write send](/docs/tutorials/write-send#step-4-parse-tools-into-events). * Write the CLI's exact version in `identity.version`, not `latest`. When the version changes silently, two eval runs are not comparing the same Agent. * When the version stays the same but the installation steps change, raise `identity.revision` as well. * `setup` only writes auth and run configuration. Leave installation to `installer`, so the version is checked before every Attempt starts. * The text in `progress` is shown in the terminal while checking, installing, and when ready. ## Run it in an Experiment Put the Agent and the Sandbox Provider in an Experiment: ```ts theme={null} // experiments/my-agent.ts import { defineExperiment } from "niceeval"; import { dockerSandbox } from "niceeval/sandbox"; import myAgent from "../agents/my-agent.ts"; export default defineExperiment({ agent: myAgent, model: "acme-model-v2", sandbox: dockerSandbox({ source: { type: "image", image: "node:22-slim" } }), }); ``` ```shell theme={null} pnpm exec niceeval exp my-agent fixtures/button ``` During the run, the terminal shows the checking, installing, and ready text from `progress` in turn. When the Sandbox already has the correct version, NiceEval uses it directly and skips installation. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. An ordinary npm package requires Node.js and npm in the Sandbox. `createNpmCliInstaller` packs on the host, uploads, then runs `npm install -g` in the Sandbox. Only a `platformPackage` that bundles its own runtime does not need Node.js and npm. ## Check installation failures When installation or the post-install recheck fails, the Attempt becomes `errored` in the `agent.ensure` phase, and the terminal gives its locator: ```shell theme={null} pnpm exec niceeval show @ ``` First check the package name, version, and target platform in the error, then confirm the image has Node.js and npm. After correcting `identity`, the npm package, or the installation steps, run the original command again. ## Choose another installation mode When packing on the host and uploading does not suit your case, `AgentInstaller` has two other modes: * `sandbox-network`: let the Sandbox install the CLI over its own network. Use it only when the task explicitly allows this network access. * `verify-only`: accept only an exact version preinstalled in the image. When the version is wrong, the Attempt reports an error directly without trying to install. Both modes require you to provide your own `AgentInstaller`. For the full fields, see the [`defineSandboxAgent` reference](/docs/reference/define-agent#definesandboxagent). # NiceEval Taste: Make Eval Criteria Clear at a Glance Source: https://niceeval.com/docs/tutorials/niceeval-taste Let a colleague read an eval and explain what it tests, what passes, why the evidence is sufficient, and how it scores. Use existing facts, simple conditions, and ordinary arithmetic. After writing an eval, ask a colleague to read its body and explain four things: what it tests, what passes, why the evidence is sufficient, and how the score is calculated. If they cannot, the criteria are still hidden in the code. This page explains the principles with examples. For declaration and run steps, see [Write evals](/docs/tutorials/authoring). For signatures, see the [defineEval reference](/docs/reference/define-eval). ## Four principles * **Write the criteria before the code.** State the business requirement in one sentence, then choose the shortest faithful expression. Do not start by aggregating logs into a number and giving it a business name. * **Check facts deterministically; use Judge for meaning.** Check tool names, arguments, and counts directly. Use Judge to assess whether an answer is faithful or advice is reasonable. * **Introduce fewer concepts.** If existing facts, simple conditions, and a little arithmetic express the rule, do not invent a new business concept. Removing a concept matters more than removing a few lines. * **Unknown means unknown.** Keep missing records, missing tokens, and oversized material indeterminate. Do not replace them with zero or false. For conversational Agents, this page is enough. For applications that provide business facts such as support records or game state, continue to [Eval criteria for custom applications](/docs/tutorials/niceeval-taste-application). ## Agent evals An Agent receives tasks through `t.send()` and returns messages, tool calls, and Turn status. NiceEval already reads these facts directly. Use its Agent assertions instead of writing another reader for tool names, arguments, or replies. ### Example: Check the weather, then give travel advice This eval has two turns. The first asks the Agent to look up Taipei's weather. The second asks whether to bring an umbrella based on that weather. It requires an Agent with a `get_weather` tool that accepts `city` and `unit`, and a project with Judge configured. ```ts theme={null} import { defineScoreEval } from "niceeval"; import { atMost, jsonMatch, toolMatch } from "niceeval/expect"; export default defineScoreEval({ description: "Look up the weather and give grounded travel advice", async test(t) { const weather = await t.send("Look up Taipei's current weather in Celsius."); await weather.succeeded().gate().orStop(); // This turn must make the correct query; calls from other turns cannot count. await weather.calledTool(toolMatch("get_weather", { input: jsonMatch({ city: "Taipei", unit: "celsius" }), status: "completed", }).exactly(1)).gate().score(20).label("Correct query").orStop(); const advice = await t.send("Based on that weather, explain whether I need an umbrella and why."); await advice.succeeded().gate().orStop(); // The check needs both the query result and the advice, so Judge gets the full history. t.closeQA("Does the final umbrella advice match the tool's weather result, without inventing weather facts?", { maxMaterialBytes: 256 * 1024, maxAuditBytes: 1024 * 1024, }).score(80).label("Grounded advice"); t.check(t.usage.totalTokens, atMost(10_000)).gate().label("Usage limit"); t.check(t.elapsedMs, atMost(30_000)).gate().label("Runtime limit"); }, }); ``` The eval makes these decisions visible: * **Where the score comes from.** The correct query earns 20 points; grounded advice earns 80. Usage and duration are gates only. * **Why the query uses a deterministic check.** The tool name and arguments are directly observable facts. Judge does not need to guess. * **Why the advice needs Judge.** A reply containing “rain” or “umbrella” does not prove the advice is correct. Even when the tool reports rain and the Agent recommends an umbrella, Judge must check that the explanation invents no weather facts. * **Why it uses `.orStop()`.** A successful query is a prerequisite for the second turn. Asking about an umbrella after a failed query is meaningless here. If your task allows the Agent to explain a query failure, write criteria for that path instead of copying this prerequisite. ### Choose the scope, then the material The same check can reach a different conclusion depending on which turn it covers and which material Judge receives. | Goal | How to write it | Common mistake | | - | - | - | | Check whether this turn called a tool correctly | Call `calledTool` on that `turn`, specifying input, status, and count | Pass the turn because a call with that name appeared anywhere in the Attempt | | Assess this turn's answer | Use `turn.closeQA(question)` with the turn's full history | Judge an explanation or refusal only by keywords | | Check whether a later answer follows earlier context and tool results | Call `closeQA(question)` on the Session covering those turns or on the whole eval | Give Judge only the last reply and make it guess the context | | Assess all calls of a particular tool kind | Use `closeQA(toolMatch(...), question)` with every matching call | Treat the first or successful call as representative of all calls | Judge needs enough material to answer the question. `toolMatch` selects only tool calls. If the question also concerns user requirements and replies, provide the full history containing them. Do not filter out failed calls, refusals, or clarifications before assessing quality; they are part of the performance. When material exceeds the limit, NiceEval records an indeterminate result instead of truncating it and continuing to score. For multi-turn tasks, decide which turn or Session owns each requirement. Use `inOrder` for tool-call ordering; see the [Match reference](/docs/reference/expect). Order alone does not prove that a later call used an earlier result. Check inputs and outputs to establish a data dependency. ### Keep deterministic facts out of Judge Both of these patterns use the wrong evidence: * `judge("Was the weather tool called?")`: the call is directly observable. Asking a model to decide again adds another chance of error. * `reply.includes("sorry")`: a keyword cannot establish whether a refusal is reasonable. Judge needs the context. Usage and duration already have fields. `t.usage` contains usage reported by the Adapter; `t.elapsedMs` is the run's wall-clock duration. Do not aggregate logs or maintain model price tables in the eval, and do not replace missing tokens with zero. Calls the Adapter did not report are absent from `t.usage`; the field's presence does not prove every external call was recorded. ## Checks before delivery Use these three steps for both Agents and custom applications. ### Separate stopping, gating, and scoring `check` registers a check. The methods chained after it determine how it is used: * `.gate()`: fail the eval if the condition is not met. * `.score(weight)`: contribute points when the condition is met. * `.orStop()`: stop later steps if the condition is not met. It controls stopping and does not replace `.gate()`. The two eval kinds have different defaults. In a score-based eval (`defineScoreEval`), an ordinary check only records its result: add `.score()` to score it or `.gate()` to make it a gate. In a pass/fail eval (`defineEval`), checks affect passing by default. Do not mix these defaults; see [Pass/fail and score-based evals](/docs/tutorials/evaluation-kinds). For a ratio you calculate between 0 and 1, use `t.score(ratio, { weight: 50 })`; its contribution is ratio × 50. With one argument, `t.score(points)` adds that many points directly. These are different meanings. A formula does not need a Match wrapper. If the ratio is unknown, pass a value marked unavailable: the item retains its weight of 50 and is not treated as zero. For a 0-to-1 measurement such as a Judge score, use `.gate(minimum)` for a minimum score and `.orStop(minimum)` to stop below it. After `.gate(minimum)`, `.orStop()` can omit the argument and reuse that minimum. Preserve unavailable evidence instead of converting it to `false` or `0` before stopping. Do not change business standards while rewriting. “Award 5 points for participating” and “everyone must participate” are different requirements. Replacing `.score(5)` with `.gate()` may look shorter, but it changes the requirement. Low and zero scores can be correct results. Do not lower gates or remove failures to make a report green. ### Verify with a real run Type checking and successful construction only confirm the form of the code. They do not prove material reading, cancellation, auditing, or business judgments are correct. After checking the installed version's exports and types, run `pnpm exec niceeval exp ` with data from the real Adapter. Do not call a Match's internal evaluator or fabricate an assertion context. After the run, open the Attempt with `pnpm exec niceeval show @` and distinguish three kinds of problems: * **Technical error**: a call or parsing step raised an error. * **Insufficient evidence**: the result is missing, partial, or unavailable. * **Business failure**: the evidence is complete, but a gate was not met. A low score is not a framework error, and zero does not mean evidence is missing. Fix the layer responsible instead of loosening assertions to get a pass. At delivery, explain separately what the `--dry` plan, type check, focused run, and real end-to-end run verified. A documentation build does not prove Judge acceptance; a few passing samples do not prove that long material or a whole batch will pass. For run and result commands, see the [feedback loop](/docs/tutorials/agent-feedback-loop). ### Review the eval's readability Before committing, ask these questions and check the answers against the same Attempt's results: * Can the reader identify the goal, observation scope, each pass condition, and each score? * Does each requirement mean “at least once,” “every time,” or “never”? Where is completeness checked? * Do combined conditions concern the same fact? Could material from another Attempt slip in? * Are model choice, actual execution, and final outcome checked separately? Is the success attributable to the subject under test? * Does Judge have all relevant material, and can the question be answered from it? * Do missing records, missing tokens, unknown cost, and capacity limits remain indeterminate instead of zero? * Can the reader see states, counts, thresholds, and combinations without opening helper implementations? * Do Matches only select material and compose small conditions? Are extra business names hiding existing fields or simple arithmetic? * Are scores and gates visible? Do dependent checks stop when a prerequisite fails? * Was verification performed through a real run and public result commands? Are focused checks distinguished from real execution? # Eval Criteria for Custom Applications: Business Facts, Domain Matches, and Plain Arithmetic Source: https://niceeval.com/docs/tutorials/niceeval-taste-application When an application supplies support records or game state, let its Adapter provide facts, compose small conditions with domain Matches, calculate scores in the eval, and preserve unknown states. Applications that supply business facts, such as support records or game state, need one more layer than Agent evals: the Adapter gives NiceEval the facts, then the eval combines conditions and calculates scores. It still uses the same `check`, `closeQA`, `.gate()`, and `.score()` methods. This page continues [NiceEval Taste](/docs/tutorials/niceeval-taste). Read its four principles first; it also contains the shared checks before delivery. ## Responsibilities | Owner | Responsibility | | - | - | | Eval body | Define the scenario, observation steps, checks, business criteria, weights, and prerequisites for later steps | | Adapter | Read application facts and provide a typed context for the current Attempt; manage connections and cleanup, correlate records, and report usage | | Domain Match | Provide reusable small criteria for identity, state, or operation type, with failure diagnostics. The eval composes the full acceptance condition | | NiceEval | Provide generic checks, composition, gates, scoring, stopping, Judge, aggregation, cancellation, and evidence auditing | The Adapter reads the application's existing protocol, execution records, and state instead of inventing another dataset. Facts passed to checks are read-only and isolated by Attempt. Keep log conversion, record correlation, and billing in the Adapter, out of the eval body. ## Start with the scenario and criteria State the business task in one sentence, then list requirements that can fail independently. For each, specify the object, whether it means “once” or “every time,” the observation window, and the evidence source. Assign weights last. Write the body in the order “what happened, what is checked, what gate and weight apply.” Comments explain why a check matters instead of repeating its method name. Separate observation prerequisites, business gates, and scoring with blank lines. Prefer another pass over the data to hiding criteria inside a general wrapper for performance. Evidence must establish the conclusion. “The recipient received the email” requires a matching send receipt and receiving record. Generating the email body only proves output exists; accepting the send request only proves processing began. Neither alone proves delivery. An abstraction should clarify criteria, not hide them. Moving thirty lines of filters into another file named `journalMatches` does not solve the problem. At the other extreme, a `runEverything()` helper that hides every check and weight makes the eval impossible to review. ## Separate reading facts from judging them A context Match has two parts: `read(ctx)` reads facts or complete material available to the current Attempt; its inner `match` decides how to judge them. NiceEval injects `ctx` when running the check. You do not pass `ctx` or the whole log as an argument. See the [Match reference](/docs/reference/expect) for signatures. Use application vocabulary close to the facts: characters, speech, operations, requests, and model responses. Log filenames and storage formats should not become the main language of evals. The Adapter defines how facts are correlated, when they are complete, and which time window a measurement covers. An application can provide checks through its Adapter's `assertions`, such as `t.said(match)`, `t.systemOne(match)`, or `t.hp(selector, collectionMatch)`. They read facts and pass them to the same `check`. These are application-defined methods, not NiceEval built-ins. Calling one registers a check; no separate scope lookup or registration step is needed. ## Keep criteria in the body, not hidden in names `winnerActedIndependentlyInCombat()` looks concise, but a reader must open its implementation to learn what it checks. Does it verify identity, a successful model call, and an adopted decision, or just find a log mentioning “combat”? Naming a function after the conclusion does not make its criteria visible. Small domain criteria can use domain names, such as character, health, decision, and operation. Each should express one explainable condition. Leave equality, counting, composition, and failure diagnostics to general combinators. Put the selected subjects, required counts, and simultaneous conditions in the eval body. The same applies to objects. “The crate arrived intact” needs three visible conditions on one object: `id === crateId`, `durability >= minimum`, and `positionId === destinationId`. If the crate must actually have been carried, check a separate operation record; its current position does not prove the process. This fragment assumes the application is connected. Combinators come from `niceeval/expect`; `t.hp`, `t.systemOne`, `t.objects`, and the character, model, decision, operation, object, durability, and position Matches are application-defined. Connect the application's Adapter first. ```ts theme={null} import { and, countWhere, equals, greaterThan } from "niceeval/expect"; // Exactly three of the selected characters have zero health. t.hp(npcMatch({ ids }), countWhere(equals(0), equals(3))).gate(); // Identity, model outcome, adopted decision, and operation type must match on the same action. t.systemOne(and( npcMatch({ id }), modelMatch({ outcome: "succeeded" }), decisionMatch({ outcome: "adopted" }), operationMatch({ ids: operationIds }), )).gate(); // Select the crate and require remaining durability and the destination position. t.objects( objectMatch({ id: scenario.carry.objectId }), and( durabilityMatch(greaterThan(0)), positionMatch({ map: scenario.map.name, ...scenario.destination }), ), ).score(50); ``` Every condition is visible: * `ids` identifies the characters in this scenario, `id` the character being checked, and `operationIds` the allowed operation types. Define them in the scenario declaration. * “All defeated” becomes a health condition plus a count; “independent action” becomes a successful model call plus an adopted decision. * `scenario` supplies the object ID, map, and destination. The first argument to `t.objects` selects the object; the second checks that same object's durability and position. The weight of 50 is written directly after them. Inside the Adapter, `t.objects` uses public combinators such as `filterWhere` and `countWhere`, then passes the result to `check`. It does not call an internal evaluator and is not a built-in NiceEval object API. The Adapter must provide correlated facts so combined conditions concern the same object. Do not build temporary data structures or expand log filters in the eval body, and do not have the Adapter return a conclusion such as “this eval passed.” A successful model call, an adopted decision, a successfully executed operation, and an achieved goal are four distinct facts. You cannot combine A's successful call, B's adopted decision, and C's operation into a pass, or interpret one successful model call as task success. An adopted decision does not prove execution succeeded; write another check when the task requires execution results. ### Collection combinators | Combinator | Purpose | | - | - | | `mapValue(label, project, match)` | Extract a value from one fact and pass it to an inner check. Use it for small criteria such as character identity or model outcome | | `filterWhere(selector, aggregate)` | Select definite matches from a complete collection and pass them, in order, to a collection check | | `mapEach(project, aggregate)` | Extract the same kind of value from each item, preserving material identity and order, then apply a collection check | | `countWhere(item, count)` | Check each item and pass the exact match count to a count check | For an incomplete collection, these combinators return an indeterminate result. They do not treat a subtotal of known items as an exact count. Check that all required characters are present separately. Three characters with zero health do not prove that every specified character was observed. An empty collection has a count of zero, which also cannot prove “everyone meets the condition.” The Adapter must preserve unknown states for missing characters or measurements instead of dropping them and claiming the collection is complete. ## Specify once, every time, and never “At least once,” “every time,” and “never” need different evidence. First limit the scope to the same Attempt, object, and observation window, then decide whether missing records affect the conclusion. | Business requirement | Sufficient evidence | Complete but empty collection | | - | - | - | | At least one qualifying action | One real, traceable instance satisfying every condition | False | | Every action qualifies | All candidates covered and checked; add “at least once” if participation is required | Depends on whether actual participation is required | | No prohibited action | No violation within the complete scope; one counterexample disproves it | True | | Ask an acceptance question about a whole set | Complete, ordered, nonempty relevant material | `closeQA` scores zero without calling Judge | Empty-collection rules depend on the check. `closeQA` scores zero, while `notCalledTool` passes. A `partial` or `unavailable` collection cannot prove an event never happened and must not be treated as empty. One qualifying instance proves “at least once,” not “all qualify.” Likewise, one violation disproves “no violations” even if the remaining material is incomplete. Mark insufficient evidence honestly instead of always treating it as a business failure. Combined conditions must concern the same object. “An action by A exists” plus “speech delivered to B exists” does not prove “A spoke to B and it was delivered.” A's movement and C's speech could satisfy those two conditions separately. ## Complete example: A support task The next three blocks form one `support-review.ts` module, in order. It exports a project-defined `createSupportReview` factory: pass the application's existing observation function and receive an Adapter and an eval. This factory is not a NiceEval API; it keeps integration code and the eval body separate and readable. Default-export the returned `evaluation` from an `.eval.ts` file and use the same returned `adapter` in the Experiment. Configure Judge before running; see [Verify Judge](/docs/tutorials/verify-judge). ### Part 1: Provide application facts and preserve unknown states `observe(ctx)` executes or waits for the support task and returns read-only facts for the current Attempt. It responds to cancellation through `ctx.signal`, registers connection cleanup callbacks as needed, and reports usage through `ctx.recordUsage`. Correlate records, decode protocols, and validate time here, outside the eval body. Completion status needs evidence: * A completed task has a completion event and a verified nonnegative `businessSeconds` value. * A task confirmed incomplete has a record showing observation fully ended. * If neither can be confirmed, return `unavailable`. The speech collection retains each utterance's evidence ID, original text, delivery state, and model origin. Mark unknown states instead of presenting them as complete records. ```ts theme={null} import { defineAdapter, type AdapterCreateContext } from "niceeval"; import { atLeast, atMost, countWhere, defineContextMatch, defineMaterialMatch, equals, filterWhere, mapEach, mapValue, type BooleanMatch, type CollectionValue, type MaterialCollection, type MatchFact, type NumericMaterial, } from "niceeval/expect"; export type Speech = { readonly actorId: string; readonly text: string; readonly delivered: boolean; readonly modelOrigin: "verified" | "unknown"; }; export type Completion = | { readonly completed: true; readonly eventId: string; readonly businessSeconds: number } | { readonly completed: false; readonly observationEndId: string }; export type SupportFacts = { readonly speeches: MaterialCollection; readonly completion: MatchFact; }; ``` ### Part 2: Domain Matches select material only Select speech only by character. Undelivered speech and speech with unknown model origin must also contribute to expression-quality assessment. `read` gets material from the injected `ctx` without registration. The read budget covers the entire collection, including nonmatching speech. Completion status and business time are already facts: read them and calculate directly. Do not create a “completion efficiency Match” just to access a field or wrap arithmetic. Keep the formula, time scale, and weight in the eval body. ```ts theme={null} export const actorMatch = (actorId: string) => mapValue("actorMatch", (speech: Speech) => speech.actorId, equals(actorId)); export const speechMaterial = (match: BooleanMatch) => defineMaterialMatch({ name: "actor-speeches", read: ctx => ctx.speeches, match, capture: { maxItems: 1024, maxBytes: 512 * 1024, maxNodes: 65536, maxDepth: 32 }, }); ``` ### Part 3: Make every criterion and weight visible This eval awards 5 points for participation, 45 for expression quality, and 50 for completion efficiency. It also has three gates: at least one support utterance was delivered, token usage stays within its limit, and runtime stays within its limit. `said` and `speechDelivery` are this Adapter's own checks. `said` checks for at least one utterance. `speechDelivery` passes the complete speech collection to combinators for selection, projection, and counting, preserving incomplete states. Neither decides the eval's pass conditions for its author. ```ts theme={null} export function createSupportReview( observe: (ctx: AdapterCreateContext) => Promise, ) { const adapter = defineAdapter({ name: "support-review", create: observe, assertions({ check }) { return { said(match: BooleanMatch) { return check(speechMaterial(match)); }, speechDelivery( selector: BooleanMatch, aggregate: BooleanMatch, CollectionValue>, ) { return check(defineContextMatch>({ name: "speech-delivery", read: ctx => ({ state: "available", value: ctx.speeches }), match: filterWhere(selector, mapEach( (speech: Speech) => speech.delivered, aggregate, )), })); }, }; }, }); const evaluation = adapter.defineScoreEval({ description: "Support speech quality and task completion efficiency", test(t) { // Participation earns points; at least one delivery is a separate business gate. t.said(actorMatch("agent")).score(5).label("Participation"); t.speechDelivery(actorMatch("agent"), countWhere(equals(true), atLeast(1))) .gate().label("At least one delivery"); // Assess all speech; do not filter by delivery or model origin. t.closeQA(speechMaterial(actorMatch("agent")), "Is every utterance natural, complete, and clear?", { maxMaterialBytes: 256 * 1024, maxAuditBytes: 1024 * 1024, }).score(45).label("Expression quality"); // Keep the formula visible; unknown retains its weight and is not a zero for non-completion. const completion = t.completion; const ratio: number | NumericMaterial = completion.state === "unavailable" ? { state: "unavailable", reason: completion.reason } : completion.value.completed ? Math.max(0, 1 - completion.value.businessSeconds / 180) : 0; t.score(ratio, { weight: 50 }).label("Completion duration"); const usage = t.usage; t.check(usage.totalTokens, atMost(10_000)).gate().label("Usage limit"); t.check(t.elapsedMs, atMost(30_000)).gate().label("Runtime limit"); }, }); return { adapter, evaluation }; } ``` Keep these distinctions in mind: * **Two kinds of budget.** The limits in `closeQA` cover Judge material and audit records; `capture` in `speechMaterial` covers reading the whole collection. These example values do not fit tasks of every length. * **Two kinds of time.** `t.elapsedMs` is the eval run's wall-clock duration. Completion efficiency uses application business time. They are not interchangeable. * **Unreported usage does not pass the check.** Read `t.usage` and `t.elapsedMs` directly as properties. Missing tokens remain unknown, not zero. * **Each score proves only its own item.** Completion at business second 90 earns 25 efficiency points; Judge must score expression quality separately. If Judge is unavailable or material exceeds capacity, the total score is incomplete. Delivery also does not prove model origin: add a deterministic check if the business requires independent model-generated speech. ## Case 1: Ask for directions, then actually meet A must ask B how to reach a meeting place, then walk there independently and meet B. The eval assesses A's choices, execution, and outcome, not whether the player completed the task for A. Judge four things separately: expression quality, useful information, actual delivery, and model origin. They require different material: * **Expression quality** uses all of A's speech in the observation window, including unheard or incorrect speech and speech with unverified origin. Keep questions, answers, refusals, and clarifications, with the necessary two-party context. Do not filter for delivered or verified speech first. * **Useful information** uses the complete delivered conversation from both sides, together with the official map. This is a design sketch. The weights are business choices for this task. It is pseudocode, not runnable code; implement application actions and material APIs using the installed version's reference. ```text theme={null} Scenario: A does not know the route; B does. Both are within speaking range. Goal: After asking for directions, A independently reaches the destination and meets B. Observe: Run the application until the task ends or the business observation deadline arrives. Prerequisite: Confirm that this Attempt's conversation, action, and outcome records are complete and can be correlated. Otherwise report unavailable evidence and stop checks that depend on it. Checkpoint 1: A delivered a request for directions to B. Gate: the same utterance satisfies actor, recipient, and delivery conditions. Checkpoint 2: Select complete material separately for the two acceptance questions. Expression: Is all of A's formal speech natural, complete, and understandable in context? Weight 2. Keep unheard, incorrect, and unverified-origin speech. Information: Does the delivered conversation provide enough information to find the destination? Weight 3. Include the official map and destination. Check model origin separately; unknown origin does not prove autonomy. Checkpoint 3: A's model chose movement; the application executed it; A arrived and met B. Record all three separately. Link the meeting to A's independent action. Independent completion is a gate and earns 5 points; player movement does not count. Result: Show each judgment and its reason. A complete score ranges from 0 to 10. Retain earned points when a gate fails. Do not fabricate a complete zero score when evidence is unavailable. ``` “Directions arrived before movement” proves only ordering. To require “A followed B's directions,” the application must correlate the choice with the input it used. If that evidence is unavailable, narrow the criterion and state the limitation. Temporal proximity is not causation. Judge questions must not suggest an answer and must state clear criteria. “Did A perform excellently and understand B?” provides no acceptance standard. Separating expression from information lets a reader explain each score independently. ## Case 2: Negotiate transport and actually deliver A and B must carry an item together. Establish the business conditions first: do both already know the goal, is negotiation required, and who can refuse? Derive criteria from those conditions instead of copying the directions task. | Scenario | Assessing communication | Assessing completion | | - | - | - | | The goal is not aligned; B explains why it cannot carry and offers a reasonable alternative | This can be effective communication; retain the refusal and response | No delivery means no completion | | Both know the goal and carry according to their agreement without speaking | If speech is not required, do not register a speech-dependent quality item | Check carrying and delivery records | | The operation was accepted but the item has not moved | Acceptance does not prove communication quality | Not delivered | | The player delivered the item; A did not carry it | This does not show that A understood the task | Does not satisfy independent completion by A | For communication quality, retain both sides' complete conversation and ask questions answerable from it. For delivery, correlate the item, destination, executor, and completion receipt. Score communication and delivery separately, with weights visible in the body. Apply the same approach to operation requests and model responses: keep failures and adjustments in operation records, actual sent content in request records, and rejected outputs in model responses. Observe choice, execution, and outcome separately; one does not substitute for another. `closeQA(materialMatch, question)` can assess all of a character's speech, operations, requests, or model responses. NiceEval passes every matching item to Judge in the original order. You need not call the model once per item in the eval. The first item, a summary, or a few successful samples cannot represent overall performance. Write a question used by only this eval directly in its `closeQA` call so readers see both material and criteria. Shared question text can be a reusable constant, but do not hide material selection, question, and weight together in a scenario-specific function. A saved final position proves only the ending location. Even if the application attaches it to every operation record, it is not the location at the time of each operation. Assess a route or step-by-step trajectory from records captured at the corresponding times. ## Common misleading patterns These are business pseudocode examples. Variables represent application facts, not NiceEval fields. | Pattern | Problem | Better approach | | - | - | - | | Send `journal.filter(isSuccess).slice(0, 12)` to Judge | Keeping only early successes hides refusals, errors, and adjustments | Select all material by business object and observation window; preserve order, text, and evidence IDs | | Assess expression with `speeches.filter(deliveredAndModelVerified)` | Excludes poor unheard speech or speech with unverified origin | Use all of the character's speech; check delivery and origin separately | | Name a `journal.map(...).reduce(...)` result `cooperation` | A statistic does not define cooperation | Define cooperation first, then check the relationship between actor, goal, and action with domain Matches | | `t.observation(...)`, `t.efficiency(...)`, `runEverything()` | Names conceal evidence and criteria, or all scoring | Write observation prerequisites, business checks, Judge questions, and points separately in the body | | `winnerActedIndependentlyInCombat()` | The name hides the judgment | Combine character identity, successful model call, adopted decision, and allowed operation type in the body | | `allThreeEnemiesDefeated()` | “Defeated” may mix health, departure, and task outcome; the count is hidden | Specify the characters, health condition, and required count separately | | `crateDeliveredIntact()` | The name conceals identity, durability, and location conditions | Combine identity, durability threshold, and destination on the same object | | `firstDecisiveGameSeconds()`, `preVictoryCallEfficiency()`, `inverseScoreMatch()` | Invents concepts for a number or formula the reader must look up | Read duration and call count for the same window from `ctx`, calculate a 0-to-1 ratio in the body, then apply the weight | | `has(actorA) && has(deliveredToB)` | Different records can satisfy each condition | Combine actor, action, and delivery conditions on one record | | `output.includes("move")` | Saying it will move does not prove movement | Check the model choice, execution receipt, and position change separately | | Treat `accepted === true` as task completion | Acceptance is not final completion | Check the matching completion receipt and target state | | `tokens ?? 0`, `cost ?? 0` | Missing data becomes free or low usage | Preserve unknown values or known lower bounds and assess budgets according to coverage | | Use `Date.now() - started` as business completion time | Wall-clock time includes waiting and eval overhead | Read business time from the application and track runtime separately | | `judge("Was the tool called?")` | Asks a model to guess a directly observable fact | Check tool-call records deterministically | | Use `reply.includes("sorry")` to judge a refusal | Keywords do not explain meaning | Give Judge full context and ask whether the refusal follows business constraints | ## Separate usage from business time Call count, tokens, and cost are different metrics. The Adapter reports usage for every real call, including failures and retries; NiceEval aggregates it. Do not maintain another model price table in the eval. Unknown cost is not zero, and a known lower bound is not a complete total. `t.usage` and `t.elapsedMs` provide general usage and wall-clock duration. Measurements such as “calls before completion” or “game time to completion” require an observation window explicitly defined by the application. Do not rename a usage snapshot “usage before completion,” or substitute game time for runtime. ## Preserve complete evidence Retain material in its original order and make it traceable to records from the same Attempt. “Complete” is relative to the declared business scope; it does not mean sending every unrelated log to Judge. Select by object, task, and observation window, not by performance. When the whole conversation matters, do not truncate text or discard refusals and clarifications. Report capacity overflow as indeterminate instead of shortening the window, sampling, or retaining only successes to obtain a score. If the business question changes, write a separate eval; its result does not answer the original task. ### Account for reading and Judge budgets separately These budgets cover different things: * **Read budget** covers the entire candidate collection, including nonmatching records. * **Judge material budget** covers the acceptance question, all matching material, and its identity information. * **Audit budget** must also fit the actual Judge request, response, and references. Few candidates, few matches, and few bytes are different properties. If you read a thousand operations and twenty belong to A, the read budget still covers a thousand. If A's twenty operations contain long text, their low count does not prove the Judge material or audit budget is sufficient. Preserve unknown results on overflow; do not treat the first twelve items as the whole set. “At least once” needs one confirmed instance. A question about the whole set needs confirmed completeness and every matching item. One qualifying action does not prove the others are good or justify hiding unread records from Judge. If a budget stops reading, even an “at least once” conclusion must follow the formal evidence record, not an inference from a truncated array. ## Calculate scores with ordinary arithmetic Completion duration is an application fact. Do not pass the entire log into the eval. The application supplies traceable completion status and business time; the eval calculates the score directly without a new function, Match, or scoring mechanism for a field lookup or arithmetic expression. For “earlier completion within 180 business seconds earns more,” use `max(0, 1 - completionSeconds / 180)` with a weight of 50: * Complete observation proves no completion: zero points. * Missing completion or observation-end record: indeterminate. * A missing completion event is not a duration of zero; wall-clock runtime is not business time. Completion at second 90 proves only that this item earns 25 points. Delivery, independent model choice, and expression quality still need separate checks. One passing example or type check does not verify the entire eval. For combat, state the waiting condition explicitly: “one specified character remains alive, or the observation deadline arrives.” The deadline does not establish a winner. Distinguish one survivor, multiple survivors, and no survivors. After stopping, read game duration and actual call count from the same window and calculate according to the business rule. Do not reconstruct a new “first decisive moment” concept from logs. This describes observation intent, not a NiceEval API for live state or deadlines. The application must supply a read-only state API and a stopping mechanism. Do not call internal evaluators, read partially saved internal storage, or assume cancellation means all ongoing operations have ended. Follow the application's state and cleanup contracts and the [Adapter reference](/docs/reference/define-agent). ## Ask Judge questions the material can answer When an external map, rules, or reference answer is a real acceptance basis, provide it explicitly with its source. Do not put the desired conclusion into the question as a premise. Judge treats instructions inside the material as content under evaluation, not instructions to execute. When reusing a domain Match, review its name, what evidence it considers sufficient, and its failure explanations. Preserve business checkpoints and separate weights. Do not add multiple aliases or registration steps for one capability. Follow the installed version's reference for interface shapes; design sketches are not runnable APIs. ## Additional checks before delivery Complete [NiceEval Taste: Checks before delivery](/docs/tutorials/niceeval-taste#checks-before-delivery), then verify the cases specific to business facts: * **Collection states**: cover complete nonempty, complete empty, missing items, and partial collections. Ensure conditions from different characters cannot combine into a pass. Treat one survivor, no winner yet, and mutual defeat as separate business cases instead of treating every non-win as a technical error. * **Real application lifecycle**: for external HTTP or a continuously running application, verify the order of cancellation, waiting for ongoing operations, and saving evidence. Check late events after cancellation, writes after saving, and honest marking of incomplete observations. A passing local Fixture does not verify the whole application lifecycle. Before committing, also ask: * Does expression-quality assessment use all of the character's speech, with delivery and model origin checked separately? * Are collection reading, Judge material, and auditing each within budget, with overflow reported honestly? * Can readers see characters, states, counts, gates, and combinations without opening helper implementations? * Are reasonable refusals, silent cooperation, and adjustments after failure handled according to this scenario's rules? * Is final state being mistaken for a step-by-step trajectory? Are business measurement windows mixed with general usage? # Configure Managed DinD on NixOS Source: https://niceeval.com/docs/tutorials/nixos-managed-dind Deploy a managed rootless Docker profile with NiceEval's NixOS module, and verify the host environment with doctor. Managed rootless DinD suits running untrusted Agents on a shared NixOS host. NiceEval's NixOS module creates a dedicated system user, a rootless Docker daemon, a capacity-limited data disk, a resource slice, and a watchdog. Day-to-day NiceEval runs do not need `sudo`, but the first deployment and system configuration updates need administrator privileges. This page only covers the NixOS deployment entry point. The Docker profile itself is a Linux host capability, not something specific to NixOS. Ubuntu, Debian, and other systemd Linux distributions need an equivalent host service installed. macOS users can run NiceEval and the matching Linux host service inside an OrbStack Linux machine; Apple `container` currently needs a separate NiceEval Provider. For the exact boundaries and commands, see [Let the Sandbox Use Docker](/docs/tutorials/docker-in-docker#use-orbstack-on-macos). If the task can trust the Agent, or runs only in a disposable VM, first read [Let the Sandbox Use Docker](/docs/tutorials/docker-in-docker) and choose Docker socket or raw privileged DinD. They do not need the host profile on this page. ## Prerequisites Before starting, confirm: * NixOS manages system configuration with flakes; * the host uses systemd and cgroup v2; * you know the NixOS user name that runs NiceEval day to day; * the host has enough CPU, memory, and disk to leave for eval containers. The steps below assume the system configuration repository already has a `flake.nix`, the configuration name is `my-host`, and the day-to-day user name is `alice`. Replace them with your own values. ## Bring in the NiceEval module Add the NiceEval input to the system configuration's `flake.nix`, and put the module into the host's `modules`: ```nix title="flake.nix" theme={null} { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; niceeval.url = "github:NiceEval/NiceEval"; }; outputs = { self, nixpkgs, niceeval, ... }: { nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ niceeval.nixosModules.default ./configuration.nix ]; }; }; } ``` After the first Nix command runs, Nix writes NiceEval's specific commit into your `flake.lock`. Commit this lock file, so different machines do not use different module versions at unconfirmed times. ## Declare the profile Declare a profile named `default` in the NixOS configuration: ```nix title="configuration.nix" theme={null} { ... }: { services.niceeval.dockerProfiles.default = { enable = true; accessUsers = [ "alice" ]; capacity = { cpus = 4; memory = "8G"; pids = 4096; maxContainers = 2; maxBuilds = 1; ephemeralDiskBytes = "6G"; dockerDataAllocationCount = 2; }; aggregate = { cpus = 6; memory = "12G"; pids = 6144; }; storage = { size = "30G"; backing = "loop-ext4"; }; }; } ``` Each group of numbers controls one thing: * `capacity`: the total NiceEval can hand out to eval tasks. * `ephemeralDiskBytes`: the most disk each Sandbox's Docker data can use. * `dockerDataAllocationCount`: the most Sandboxes that can hold Docker data space at once. * `aggregate`: the hard cap on the Docker daemon, build processes, host services, and eval containers combined; each item must not be smaller than `capacity`. The difference between them is left for host-side overhead. `backing = "loop-ext4"` creates a separate ext4 filesystem at `/var/lib/niceeval/docker-profiles/default.img` and limits disk usage per directory. The host service gives each Sandbox a dedicated directory from it, mounted at the Sandbox's `/var/lib/docker`. Do not declare `tmpfs` for this path as well: Docker images and BuildKit cache in the Sandbox should use disk and should not count toward the host's `Shmem`. This shared loop filesystem only handles run-time disk quota and isolation; it cannot save the results of preparation steps. Preparation steps that declare `sandboxState.dockerData` show `Unsupported` in `niceeval debug` and really execute every time. To let such steps be reused directly next time, you need the fixed storage configuration in the next section; for the caching rules of preparation steps, see [Choose and Configure a Sandbox](/docs/tutorials/sandbox-providers). ## Let Docker preparation steps be reused next time When preparation steps such as `docker load` are slow, you may want the Docker images and volumes in the Sandbox to be reused directly next time. This needs a separate raw profile with fixed-size storage: ```nix title="configuration.nix" theme={null} services.niceeval.dockerProfiles.harness-raw = { enable = true; securityLevel = "raw-dind-storage/v1"; rawDockerSocket = "/run/docker.sock"; rawDockerRootDir = "/var/lib/docker"; capacity = { cpus = 8; memory = "16G"; pids = 4096; maxContainers = 2; maxBuilds = 1; ephemeralDiskBytes = "4G"; dockerDataAllocationCount = 2; }; aggregate = { cpus = 10; memory = "20G"; pids = 6144; }; storage = { size = "80G"; backing = "fixed-image-ext4"; rootDir = "/data/niceeval/docker-profiles/harness-raw"; }; setupPrefix = { enable = true; seedCount = 10; }; }; ``` ### Estimate disk space This configuration pre-allocates: * 2 run spaces of 4 GiB each (matching `dockerDataAllocationCount = 2`); * 10 read-only seeds of 4 GiB each (`seedCount = 10`), used to save prepared Docker state; * 2 worst-case temporary copies. That is 56 GiB in total. With `storage.size = "80G"`, after ext4 metadata and reserved blocks are deducted, the remaining available space (`f_bavail`) must still be enough for failure recovery. Seeds are not cleaned up automatically. Once all 10 are used, the profile refuses to save new preparation results and does not overwrite old seeds. `fixed-image-ext4` can only be used with a raw profile. It keeps using the existing daemon's `DockerRootDir`, but puts fixed storage, host configuration, and logs under a separate `fixed-image-v1` path. `rootDir` must be an absolute path that is not the root directory; mount dependencies, manifests, and writable paths are all derived from it. ### Switch to fixed storage Before switching, make this profile completely idle: * no running or queued Attempts, builds, or containers; * no NiceEval proxy containers or Attempt networks on the default daemon. Deployment does not rewrite the old `loop-ext4` image file. The old image, configuration, and logs are all kept, so you can roll back with downtime if needed. Enabling fixed storage is a separate deployment operation; it does not happen automatically with `nixos-rebuild switch` or at boot. Start the two services in order: ```bash theme={null} sudo systemctl start niceeval-docker-profile-fixed-activation-harness-raw.service sudo systemctl restart niceeval-docker-profile-fixed-watchdog-harness-raw.service ``` Start or restart the second only after the first succeeds. Each successful activation saves a read-only version snapshot, and the `current` pointer switches to the new version atomically. On a normal boot, the host service finds the current version through the `current` pointer, checks the mount holding `rootDir`, the outer ext4, the seed registry, and checksums, then restores the data mounts. When any of these does not match, the host service does not start accepting Attempts. When the data disk does not exist, it does not create a same-named directory on the root disk and keep going either. ### Before the seeds run out When seeds are nearly used up, first check the activation service's status, which lists the capacity currently available, reserved, retirable, and reclaimable. Replacing seeds is manual: stop accepting new Attempts, wait for existing tasks to finish, and publish a new version under the exclusive lock. An old version must be explicitly retired before its space can be reclaimed. Only a version that is neither the current one nor the previous one can be retired. After reclamation, that version can no longer be used for rollback. ## Apply the system configuration First check that the configuration evaluates, then switch the system: ```bash theme={null} nix flake check sudo nixos-rebuild switch --flake .#my-host ``` The module adds `alice` to the profile's access group. After the rebuild, log out of the current session and log in again so the new group membership takes effect. Rerunning commands in an old shell does not refresh supplementary groups. After logging in again, check the services: ```bash theme={null} systemctl status niceeval-docker-profile-default.service systemctl status niceeval-docker-profile-watchdog-default.service ``` Both services should show `active (running)`. On failure, read the matching unit's logs: ```bash theme={null} journalctl -u niceeval-docker-profile-default.service -b journalctl -u niceeval-docker-profile-watchdog-default.service -b ``` ## Verify the profile Go into an eval project that has NiceEval installed, and run as the day-to-day user: ```bash theme={null} pnpm exec niceeval docker profile list pnpm exec niceeval docker profile doctor default ``` `list` should list `default`. `doctor` checks, in order, the profile descriptor, Unix socket, cgroup, capacity, host services, offline resources, and one cold build, then starts a restricted outer container and runs an Alpine container inside it. Only after everything passes can this profile be used for formal evals. When one item fails, `doctor` points out which. First check the matching service's logs as in the previous section. Do not bypass permission errors with `sudo pnpm exec niceeval ...`. When the day-to-day user cannot access the profile, first confirm the user name is in `accessUsers`, then log in again and rerun doctor. ## Use it in an Experiment After the host passes acceptance, reference the same alias in the Experiment's Docker Sandbox: ```ts theme={null} dockerAccess: { mode: "dind", isolation: "managed-rootless", profile: "default", }, ``` The Experiment must also declare each Sandbox's CPU, memory, process count, `dockerDataBytes`, a read-only root filesystem, and tmpfs for the other writable paths. For the full configuration, see [Let the Sandbox Use Docker](/docs/tutorials/docker-in-docker#managed-rootless-dind). Run once with a small `maxConcurrency` first, then raise concurrency based on the profile's `capacity`. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. ## Update the module When you need to update the NiceEval module, update the matching input in the system configuration repository, check the diff, and rebuild: ```bash theme={null} nix flake update niceeval nix flake check sudo nixos-rebuild switch --flake .#my-host ``` Updates change the host services, so updating only the npm dependency in the eval project is not enough. Keep the old NixOS generation, and clean it up only after `doctor` and real evals both pass. # Reuse a Complete Set of Run Conditions with Plugins Source: https://niceeval.com/docs/tutorials/plugins When the same set of conditions, "start services on the host, then prepare the environment in the Sandbox", is used in several Experiments, eval groups, or evals, write it once with definePlugin and attach it anywhere. Several of your comparison experiments connect to the same memory service: open a tunnel on the host, then write connection configuration and install command-line tools in the Sandbox. If you copy this code into every Experiment, one change means editing several places. A Plugin puts such a complete set of conditions in one place: what it is called, what to start and stop on the host, and which preparation commands to run in the Sandbox. Once written, attach it to an Experiment, eval group, or eval to reuse it. A Plugin is only responsible for the conditions themselves, not for choosing the environment: which Docker image, E2B template, or Vercel snapshot to use is still decided by the `sandbox` field. The Agent's own Skills, MCP servers, and native Plugins do not belong here either. They are still passed to the Agent factory, for example `codexAgent({ plugins: [...] })`. ## Step 1: Write a Plugin Write the conditions as a Plugin and put it where several Experiments can import it. All Plugin APIs are imported from `niceeval/plugin`. The example below starts and stops a memory service on the host, and uploads plugin files and writes connection configuration in the Sandbox: ```ts theme={null} // experiments/shared/memory.ts import { definePlugin } from "niceeval/plugin"; import { changeFrequency, sandboxLayer, uploadDirectory, writeText, } from "niceeval/sandbox"; type MemoryOptions = { readonly model: string; }; export const memory = definePlugin({ name: "acme.memory", behaviorRevision: "2", instanceKey: ({ model }) => model, experiment: ({ model }) => ({ identity: { model }, setup: (context) => { context.progress({ message: `Starting ${model} memory service` }); }, teardown: (context) => { context.progress({ message: `Stopping ${model} memory service` }); }, }), sandbox: ({ model }) => sandboxLayer() .before(uploadDirectory({ id: "memory-plugin-files", source: new URL("fixtures/memory-plugin/", import.meta.url), to: ".agents/plugins/memory", changeFrequency: changeFrequency.rare, })) .before(writeText({ id: "memory-plugin-config", path: ".memory/config.json", text: JSON.stringify({ model }), changeFrequency: changeFrequency.frequent, })), }); ``` Each field controls one thing: * `name` and `instanceKey` together distinguish different instances of the same Plugin; here they are distinguished by `model`. * `setup` / `teardown` in `experiment` run on the host and suit starting and stopping services or opening tunnels. * `sandbox` returns a `sandboxLayer()`, which prepares the Sandbox with the same `.before()` / `.after()` as ordinary code. Preparation steps in the Sandbox are written with built-in functions such as `uploadDirectory()`, `writeText()`, `gitCheckout()`, and `shell()`. They are ordered together with the preparation steps in the Experiment and evals, and unchanged steps can reuse the last preparation result directly on Docker. A Plugin does not need to handle uploads or clones itself. For branches that can only be decided at run time, write `.before(async (sandbox, context) => ...)` in the returned layer. Such a callback really executes every time, and the preparation steps after it no longer reuse the cache. After successfully acquiring a resource, register its release right away with `context.onCleanup()`. `sandbox` must return `sandboxLayer()`. Returning a layer that chooses an image or template, such as `dockerSandbox()` or `e2bSandbox()`, reports an error when the definition loads. A Plugin cannot replace the Provider the caller chose. ### Get Plugin source from a Git repository When the Plugin source is in a repository you can clone, use `gitCheckout()` with a full commit ID in `ref`. Branches and tags move, so NiceEval cannot tell from them whether content changed, and they are not accepted: ```ts theme={null} import { gitCheckout, sandboxLayer, } from "niceeval/sandbox"; const pluginSource = sandboxLayer().before(gitCheckout({ id: "memory-plugin-source", repository: "https://github.com/acme/memory-plugin.git", ref: "0123456789abcdef0123456789abcdef01234567", to: ".agents/plugins/memory", })); ``` When the source is in a local directory, use `uploadDirectory()`, with `source` written as `new URL("...", import.meta.url)` relative to the current file. Changes to file contents, permissions, and symlinks in the directory are detected automatically: change one file, and only the step that uploads this directory and the steps after it run again. ## Step 2: Attach it to an Experiment, eval group, or eval When you use only one or two Plugins, pass an array directly: ```ts theme={null} export default defineExperiment({ agent: codexAgent(), plugins: [memory({ model: "gpt-5.6-luna" })], sandbox: dockerSandbox({ source: { type: "image", image: "niceeval/codex:0.144.1-r1" }, }), }); ``` When several Experiments share a set of Plugins and each adds a few more, combine them with `pluginStack()`. `.use()` appends one Plugin, and `.concat()` merges two sets. Each call returns a new value and leaves the original set unchanged, so you can safely branch from the same base: ```ts theme={null} import { pluginStack } from "niceeval/plugin"; const observability = pluginStack().use(telemetry()); const withMemory = observability.use(memory({ model: "gpt-5.6-luna" })); export default defineExperiment({ agent: codexAgent(), plugins: withMemory, sandbox: dockerSandbox({ source: { type: "image", image: "niceeval/codex:0.144.1-r1" }, }), }); ``` Type checking blocks Plugins attached in the wrong place: a Plugin that only supports evals cannot go into an Experiment's stack, and two sets with no common attach point cannot be merged. An empty `pluginStack()` has the same effect as omitting `plugins`. Stacks are not deduplicated automatically. When two Plugins with the same `name` and `instanceKey` appear at the same place, the run reports an error before creating the Sandbox. Remove the duplicate, or give it a different `instanceKey`. ## How many times host-side preparation runs A Plugin can declare host-side `setup` / `teardown` at three levels: | Written in | How many times it runs | | - | - | | `experiment(options)` | Once per Experiment that really runs this time | | `group(options)` | Once per combination of Experiment and eval group | | `eval(options)` | Once per Attempt that really runs | Each level must have at least one of `setup` or `teardown`, and can carry `identity`. Several Plugins' `setup` run in attach order, and `teardown` runs in reverse order. When a whole eval reuses last results and does not need to rerun, these callbacks do not run. `sandbox(options)` is not a fourth level. It defines Sandbox preparation steps only once, and NiceEval places them into the Sandbox preparation of wherever the Plugin is attached. You do not need to pass the Plugin again in `.before()`. ## Invalidate old results after changing a Plugin When a Plugin's behavior changes, reusing last results is no longer trustworthy. NiceEval can see some changes, and you need to tell it about others: * When you change the behavior of host-side `setup` / `teardown`, increase `behaviorRevision` by one. NiceEval cannot see changes in function bodies. * When you change built-in preparation steps in the Sandbox (commands, files, commits), you do not need to do anything; these changes are detected automatically. * Add a `cache.fingerprint` to a custom preparation step only for protocol changes NiceEval cannot observe. After changing it, first confirm with `pnpm exec niceeval exp --dry` that the affected evals will rerun. ## Check which steps a Plugin adds before running After attaching a Plugin, see where its preparation steps land before spending money on a run: ```bash theme={null} pnpm exec niceeval debug compare/codex memory/smoke pnpm exec niceeval debug compare/codex memory/smoke --json ``` `debug` puts the steps the Plugin adds into the same plan as the steps of the Experiment, eval group, eval, and Agent. In the terminal, each step shows where it came from, its execution number, `changeFrequency`, dependencies, and whether it can reuse the cache; `--json` gives the same structure. When the Plugin's steps do not appear in the plan, first check that `plugins` is attached to the Experiment or eval you selected. ## Read next * [Sandbox · Prepare the Sandbox in order](/docs/tutorials/sandbox-providers#prepare-the-sandbox-in-order): preparation steps, frequency, cache, and cleanup. * [Eval Groups](/docs/tutorials/eval-groups): how an eval group's preparation steps enter the same order. * [Check the Sandbox Plan Before Running](/docs/tutorials/debug-lifecycle-plan): view the complete plan after a Plugin is added. # Prebuilt Sandbox Environments: Start Each Eval Faster Source: https://niceeval.com/docs/tutorials/prebuilt-sandbox Bake system packages, Agent CLIs, and large caches into a Docker image, Vercel snapshot, or E2B template derived from an official baseline; carry run-time caches between Attempts with checkpoints. Every Attempt starts from the same starting point. System packages, Agent CLIs, compiled binaries, and large model caches are stable, large, and identical every time. Instead of reinstalling them for every Attempt, bake them into a prebuilt environment before running evals, and let Attempts start directly from the installed environment. The three Providers reference prebuilt environments the same way: ```ts theme={null} dockerSandbox({ source: { type: "image", image: "my-evals:node24" } }) vercelSandbox({ snapshotId: "snap_abc123" }) e2bSandbox({ template: "my-evals" }) ``` NiceEval only reads the ID of the build result and does not provide a cross-Provider build command. Docker images, Vercel snapshots, and E2B templates differ in credentials, build method, publishing, and expiry rules. Maintain build scripts with each Provider's own official tools, and finally write the ID or name into the Experiment. ## It runs without prebuilding too Prebuilding is only for speed. The Adapter and the Sandbox each handle their own side: the Sandbox configuration decides which Provider to use and which prebuilt environment to start from; the Adapter checks whether the CLI it needs is present. When Claude Code or Codex is missing its CLI, it is installed at run time: the installation package is prepared outside the eval network, then sent into the Sandbox through the file interface. * What Codex sends into the Sandbox is a native installation package with its own runtime, so it installs even when the image has no Node. * Claude Code requires npm in the image. * The default Bub 0.4.0, besides the OTel plugin, also pins `any-llm-sdk==1.17.0` and `openai==2.31.0`. When any of the Bub version, these two dependencies, the OTel plugin, or the Python plugin list changes, it reinstalls; `command -v bub` alone cannot show that it is installed. A prebuilt environment made with an old installation marker reinstalls once. When you explicitly choose another Bub version, the other packages it depends on are your responsibility. ## Continue building from the official baseline All three built-in Providers can continue building from an official baseline, so you do not install the Agent from a blank environment. A prebuilt environment is built only once when dependencies change, with a new versioned name for each build. Do not put the build in a dynamic callback that runs for every Attempt. ### E2B: derive from official and public templates E2B officially provides the `claude` template for Claude Code and the `codex` template for Codex. NiceEval provides a thin E2B-specific wrapper that lets you keep building from these two official starting points with E2B's native API. E2B has no official Bub template, so the Bub branch uses the same pinned dependencies and OTel plugin as `bubAgent()`. NiceEval also publishes three public templates that any E2B Team can reference. NiceEval maintains their full names and versions; use the constants directly: ```ts theme={null} import { NICEEVAL_CLAUDE_CODE_E2B_TEMPLATE, NICEEVAL_CODEX_E2B_TEMPLATE, NICEEVAL_BUB_E2B_TEMPLATE, } from "niceeval/sandbox/e2b-template"; e2bSandbox({ template: NICEEVAL_CLAUDE_CODE_E2B_TEMPLATE }) e2bSandbox({ template: NICEEVAL_CODEX_E2B_TEMPLATE }) e2bSandbox({ template: NICEEVAL_BUB_E2B_TEMPLATE }) ``` These templates have all been verified by actually starting them: Claude Code `2.1.207`, Codex `0.144.1`, and Bub `0.4.0`. The template version follows the Agent inside it, in the form `0.144.1-r1`: the Agent version plus NiceEval's build revision. The three Agents release independently, unrelated to the NiceEval library's version. So use the constants in your project, do not copy these strings, and you do not need to track versions yourself. When a derived template needs to record its base template, use the constant's value too. In this batch of public templates, the Claude Code template keeps E2B's official `/usr` npm prefix, so ordinary users cannot write global modules directly. When you need to install more global Node tools such as pnpm or yarn at run time, install them explicitly into `/usr/local`, which both templates already put on PATH: ```ts theme={null} await t.sandbox.runCommand("npm", [ "install", "-g", "--prefix", "/usr/local", "pnpm@10.34.5", ]); ``` Do not switch to `sudo npm install -g`; it can conflict with root global packages already in the template. NiceEval will unify this default prefix in templates published later; until the release notes say a new template handles it, keep `--prefix /usr/local`. Derive your own template from the official Codex template: ```ts title="scripts/build-e2b-template.ts" theme={null} import { Template } from "e2b"; import { e2bCodingAgentTemplate } from "niceeval/sandbox/e2b-template"; const template = e2bCodingAgentTemplate("codex") .aptInstall(["ripgrep", "jq"]) .runCmd("corepack enable && pnpm --version") .copy("fixtures/toolchain.lock", "/opt/evals/toolchain.lock"); await Template.build(template, "acme-codex-evals:0.144.1-r1", { cpuCount: 2, memoryMB: 4096, }); ``` ```bash theme={null} e2b auth login pnpm tsx scripts/build-e2b-template.ts ``` Then reference only the build result in the Experiment: ```ts theme={null} import { e2bSandbox } from "niceeval/sandbox"; sandbox: e2bSandbox({ template: "acme-codex-evals:0.144.1-r1" }) ``` You can also derive directly from a NiceEval public template and pay build time only for your project's own dependencies: ```ts theme={null} import { NICEEVAL_CODEX_E2B_TEMPLATE } from "niceeval/sandbox/e2b-template"; const template = Template() .fromTemplate(NICEEVAL_CODEX_E2B_TEMPLATE) .aptInstall(["ripgrep", "jq"]) .runCmd("corepack enable"); ``` `e2bCodingAgentTemplate("claude-code" | "codex" | "bub")` returns E2B's native `TemplateBuilder`, so you can keep using E2B capabilities such as `.aptInstall()`, `.runCmd()`, and `.copy()`. The built alias pins the official starting point and project dependencies into one reproducible result. When dependencies change, rebuild and use a new versioned alias. When the Bub Adapter is configured with `pythonPlugins`, pass the same set of packages to the factory when building the template. Only then does the run time recognize that the plugins are preinstalled: ```ts theme={null} e2bCodingAgentTemplate("bub", { bubPythonPackages: ["bub-plugin-memory==1.3.0"], }) ``` ### Docker: use NiceEval-maintained images, or derive from the official node base image To run NiceEval's built-in `claude-code`, `codex`, or `bub` Adapter directly, use the matching public image: [`niceeval/claude-code`](https://hub.docker.com/r/niceeval/claude-code), [`niceeval/codex`](https://hub.docker.com/r/niceeval/codex), or [`niceeval/bub`](https://hub.docker.com/r/niceeval/bub). Each image installs only its own Agent CLI and comes in both `linux/amd64` and `linux/arm64`. Tags match the corresponding E2B public templates, and the version is the version of the Agent in the image. Stable CI should use a named constant or digest, not the changing `latest`: ```ts theme={null} import { NICEEVAL_CODEX_DOCKER_IMAGE, dockerSandbox } from "niceeval/sandbox"; sandbox: dockerSandbox({ source: { type: "image", image: NICEEVAL_CODEX_DOCKER_IMAGE } }) ``` These are public images maintained by NiceEval, not Docker's official `library/*` images. A new tag is published only when the Agent version or build method changes, independent of the NiceEval library's release cadence. When you only need one Agent, or need to add your project's own dependencies, write a Dockerfile derived from Docker's official `node:24-slim`: ```dockerfile title="Dockerfile" theme={null} FROM node:24-slim # The slim image lacks ca-certificates / git, which both the Agent and npm need RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates git \ && rm -rf /var/lib/apt/lists/* # npm installs globally into /usr/local/bin, which is on the Sandbox's PATH RUN npm install -g @openai/codex@0.144.1 ``` ```bash theme={null} docker build -t acme-codex-evals:0.144.1-r1 . ``` Then reference only the build result in the Experiment: ```ts theme={null} import { dockerSandbox } from "niceeval/sandbox"; sandbox: dockerSandbox({ source: { type: "image", image: "acme-codex-evals:0.144.1-r1" } }) ``` A Docker Sandbox runs commands as the user the image itself declares. The Dockerfile above does not set `USER`, so it runs as root by default; `/usr/local/bin` is already on root's PATH, and commands installed with `npm install -g` work directly. When you need a non-root user (for example, Claude Code refuses `--dangerously-skip-permissions` under root), add a line `USER node` to the Dockerfile; `node:24-slim` includes this user. You can also override it with `dockerSandbox({ source: { type: "image", image }, user: "node" })`. When the Agent is installed elsewhere (for example `~/.local/bin`), remember to add that directory to PATH. `dockerSandbox` must spell out the image, and stable CI references a tag that does not change. ### Vercel: take a snapshot from the official runtime Vercel has no template repository like E2B and does not use Dockerfiles. A Vercel snapshot is taken from a running microVM: use the Vercel SDK to start a Sandbox from the official runtime (`node24`), install the Agent CLI, call `.snapshot()` to get `snap_...`, then pass it to `vercelSandbox({ snapshotId })`: ```ts title="scripts/build-vercel-snapshot.ts" theme={null} import { Sandbox } from "@vercel/sandbox"; const sandbox = await Sandbox.create({ runtime: "node24" }); // Start a microVM from the official runtime await sandbox.runCommand({ cmd: "npm", args: ["install", "-g", "@openai/codex@0.144.1"], sudo: true, // Install globally into /usr/local/bin, which is on the Sandbox's PATH }); const { snapshotId } = await sandbox.snapshot(); console.log(snapshotId); // snap_... await sandbox.stop(); ``` ```bash theme={null} pnpm tsx scripts/build-vercel-snapshot.ts ``` Then reference the printed ID in the Experiment: ```ts theme={null} import { vercelSandbox } from "niceeval/sandbox"; sandbox: vercelSandbox({ snapshotId: "snap_xxx" }) ``` Vercel snapshots cannot be published publicly the way E2B templates can. A snapshot ID is controlled by the permissions of the Team/Project that created it; members of the same project can reuse it, and others must take their own in their Vercel Project. The NiceEval project's own verified, never-expiring snapshot is `snap_7sIjfs71xfmVly0WEUTGhTBoMGeL`, but it cannot be used across accounts. ## Run-time checkpoints `createCheckpoint()` / `restoreCheckpoint()` solve a different problem: pack specified Linux paths in a Sandbox into a `Buffer`, then restore them into another Sandbox that has already been created: ```ts theme={null} import { createCheckpoint, restoreCheckpoint } from "niceeval/sandbox"; const data = await createCheckpoint(sandbox, ["/home/user/.cache/tool"]); await restoreCheckpoint(nextSandbox, data); ``` It suits run-time caches. It does not produce a publishable image, template, or snapshot, and it does not handle sharing, versions, or expiry. A failed pack or restore throws directly. To carry these directories between Attempts, call `restoreCheckpoint()` in a dynamic `.before()`. After a successful restore, register `createCheckpoint()` with `context.onCleanup()`, so the save completes before the Sandbox is destroyed. `sandboxReuse: true` keeps the same Sandbox only within one `niceeval exp` command. When several terminals run at the same time and all read and write the same checkpoint, declare `sharedState: { key }` at the top level of the Experiment. NiceEval acquires the lease for this key before Experiment `setup` and Sandbox creation, and releases it only after the checkpoint is saved, the Sandbox is destroyed, and Experiment `teardown` completes. It only guarantees that one side uses it at a time; it does not save the checkpoint, roll back on errors, or coordinate across machines. # Quickstart: Run Your First Eval Source: https://niceeval.com/docs/tutorials/quickstart Install NiceEval, write an Adapter, an Experiment, and an eval, get your first eval running against your own application in about ten minutes, and see the results in the terminal and the browser. This page walks you through running your first eval against your own application. You write three files: * **Adapter**: tells NiceEval how to call your application. * **Experiment**: says which Adapter to run against and with which model. * **Eval**: says which message to send and what counts as correct. There are two ways to do it. Having a Coding Agent write them is fastest; writing them yourself shows more clearly what each file is for. ## Let a Coding Agent integrate it for you Open the Coding Agent you use in the current project, such as Claude Code or Codex, and send this message: ```text theme={null} Read https://niceeval.com/INIT.md, install and integrate niceeval into the current repository, and get the first eval running end to end. When done, list the Experiment ID you created, the run command, and the command to view results. ``` The Agent first explores your project, confirms which interface to test, then writes the three files and runs them once. Run with the Experiment ID the Agent listed at the end. If it did not list one, first see which Experiments exist with `pnpm exec niceeval exp list`: ```bash theme={null} pnpm exec niceeval exp ``` ```bash theme={null} pnpm exec niceeval show ``` The terminal lists each eval's result, with failures first. For how to read the results, see [Read the results](#read-the-results) below. ## Integrate it yourself ### Prepare the application under test First start your application and confirm it has an interface you can call. The example below assumes: * the interface is `POST http://localhost:3000/chat`; * the request body is `{ "message": "...", "model": "..." }`; * the response body is `{ "reply": "..." }`. If your interface differs, that is fine; you only change the address, the request body, and the line that reads the reply in the Adapter. If you do not have an application you can start yet, pick a runnable project from [Examples](/docs/examples) first. ### Install ```bash theme={null} pnpm add -D niceeval pnpm exec niceeval init ``` `init` creates `niceeval.config.ts`. It also writes a section into `AGENTS.md` or `CLAUDE.md` so your Coding Agent reads the docs that match the installed version. ### Write the Adapter The Adapter does one thing: send the eval's messages to your application, then hand the reply back to NiceEval. ```ts theme={null} // agents/my-agent.ts import { completeEvidenceCoverage, defineAgent } from "niceeval/adapter"; export default defineAgent({ name: "my-agent", evidenceCoverage: completeEvidenceCoverage, async send(input, ctx) { // Replace with your own application's address const r = await fetch("http://localhost:3000/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ message: input.text, model: ctx.model }), // ctx.model comes from the Experiment signal: ctx.signal, }); const body = await r.json(); return { status: r.ok ? "completed" : "failed", events: [{ type: "message", role: "assistant", text: body.reply }], }; }, }); ``` `events` is how NiceEval understands the reply. Here it hands back only one assistant message; later you can add events for tool calls, usage, and more. ### Write the Experiment ```ts theme={null} // experiments/my-agent.ts import { defineExperiment } from "niceeval"; import myAgent from "../agents/my-agent.ts"; export default defineExperiment({ description: "my-agent baseline", agent: myAgent, model: "gpt-4o", attempts: 1, }); ``` The file name is the Experiment ID, here `my-agent`. To compare another model later, copy this file and change `model`; the evals stay the same. ### Write the eval ```ts theme={null} // evals/refund-policy.eval.ts import { defineEval } from "niceeval"; import { pattern } from "niceeval/expect"; export default defineEval({ description: "Answers the refund policy correctly", async test(t) { await t.send("What is your refund policy?"); t.succeeded(); t.check(t.reply, pattern(/30\s*days|one month/i)); }, }); ``` The model's wording changes every time, so this uses a regex to match "30 days" or "one month" instead of requiring an exact match. ### Run ```bash theme={null} pnpm exec niceeval exp my-agent ``` The terminal shows progress while it runs. When it finishes, you see output like this: ```text theme={null} ╭─ PASSED ─────────────────────────────────────────────────────── 12s ─╮ │ 1 passed · 0 failed · 0 errored (0 reused) │ ╰──────────────────────────────────────────────────────────────────────╯ ╭─ RESULTS ───────────────────────────────────── 1 run configuration ─╮ │ my-agent │ │ refund-policy 1/1 passed │ ╰──────────────────────────────────────────────────────────────────────╯ ╭─ NEXT ───────────────────────────────────────────────────────────────╮ │ my-agent │ │ show: niceeval show --run 8f3d6f62-1d34-4cf3-99c7-84ba3c483706 │ │ view: niceeval view --run 8f3d6f62-1d34-4cf3-99c7-84ba3c483706 │ ╰──────────────────────────────────────────────────────────────────────╯ ``` Your first eval is now running. ## Read the results The title tells you the conclusion of this run: | Title | Meaning | What to do first | | - | - | - | | `PASSED` | All assertions were met. | Keep adding evals. | | `FAILED` | The application replied normally, but at least one assertion was not met. | See what the Agent actually replied, and decide whether the application is wrong or the assertion is too strict. | | `ERRORED` | No result could be obtained, for example the application was not running, the interface returned an error, or the Adapter threw an exception. | Look at the `error:` line in the `FAILURES` panel, which usually shows the cause directly; for more detail, run the `details:` command below it. | Each failed Attempt has a line `details: niceeval show @` below it. Copy it to see that run's conversation and assertions in the terminal. To see the whole run, copy the `show:` command in the `NEXT` panel; to browse the conversation and durations in the browser, copy the `view:` line. For more ways to view results, see [View Run Results](/docs/tutorials/viewing-results). ## Next steps This minimal Adapter only supports single-turn conversations: the second turn does not carry the first turn's history, and tool calls are not visible. Choose based on what you need next: * To test multi-turn conversations, tool calls, or usage: extend the Adapter following [Write send](/docs/tutorials/write-send); existing evals stay unchanged. * To write more evals that tell results apart better: read [Write Evals](/docs/tutorials/authoring). * To compare two models or two prompt versions: read [Organize Experiments](/docs/tutorials/write-experiment). * To run automatically on PRs: read [CI Integration](/docs/tutorials/ci-integration). # Report Run Results to External Systems Such as Braintrust Source: https://niceeval.com/docs/tutorials/reporters When your team already uses Braintrust or its own dashboard, use a Reporter to send results there during the run; use JUnit for CI platforms; and use the Run ID in the receipt for later scripts to read results. Your team already looks at experiment results in Braintrust, or has an internal dashboard. You want results to show up there automatically after NiceEval runs, without exporting them by hand. That is what a Reporter does: during this one run, it sends results to an external system. It only handles reporting for "this run", and the results NiceEval saves do not change because of it. To reread, compare, or share results afterwards, you still use `show`, `view`, and `query`. One `niceeval exp` produces all of these at once: ```text theme={null} This run ├─ Terminal output, or NDJSON from --json ├─ Reporter → external system ├─ --junit → JUnit file for the CI platform └─ receipt → invocationId and createdRunIds Saved results (.niceeval/record.sqlite) └─ Runs, Attempts, and viewable run records ``` ## Choose the right way to report | You want to | Use | | - | - | | Push results to Braintrust or an internal dashboard | Reporter | | Have the CI platform show results per test case | `--junit ` | | Let later scripts find this run's results | The receipt from `--json` output | ## Push to an external system with a Reporter Configure the project's default Reporters in `reporters` in `niceeval.config.ts`; an eval's `reporters` are merged with them. For field descriptions, see the [defineConfig reference](/docs/reference/define-config). A Reporter gets the results known during this run. The external system can receive a run summary, each Attempt's result, or viewing links. To read the complete results, reopen the saved results with the Run ID from the receipt. A Reporter cannot: * treat data in the external system as NiceEval results; * replace the verdicts, usage, assertions, or diagnostics NiceEval saves; * make `query`, `show`, or `view` read data from the external system instead; * write the external system's results back into `.niceeval/record.sqlite`. ## Output JUnit in CI The most direct way to connect a CI platform is JUnit: ```sh theme={null} pnpm exec niceeval exp ci --junit ./artifacts/niceeval-junit.xml ``` NiceEval writes a temporary file first, then replaces the target file in one step, so the CI platform never reads half-written content. JUnit only describes this run, and the results NiceEval saves remain authoritative. For the full CI configuration, see [Run Evals Automatically in CI](/docs/tutorials/ci-integration). ## Link later steps with the receipt ```sh theme={null} pnpm exec niceeval exp checkout --json ``` `--json` outputs this run's NDJSON. The last line is the receipt, containing `invocationId`, `createdRunIds`, `publicationCutoff`, the start time, the end time, and `completion`. External automation can save the receipt, then open the matching Runs with `createdRunIds`: ```sh theme={null} pnpm exec niceeval show --run 01J9ZK3M6P4T7V9X2C5N8QW0RY pnpm exec niceeval view --run 01J9ZK3M6P4T7V9X2C5N8QW0RY ``` When a script needs structured results, first run `pnpm --silent exec niceeval query discover` to find the available queries. Do not treat progress or diagnostic lines as a long-term result format. They only describe what is happening in this run. ## Principles for connecting external systems 1. External reporting uses only information known during this run. 2. Credentials go in environment variables and are never written into any output. 3. CI gates look at the exit status. 4. Use the receipt's `createdRunIds` to link external workflows with the results NiceEval saves. 5. When results need review, people open that Run with `show` or `view`, and automated flows use `query`. # Rerun Only the Evals Affected by a Change Source: https://niceeval.com/docs/tutorials/rerun-and-cache After changing a prompt, an eval, or the model, use --dry to see which results will be reused and which will rerun, then control the scope with --rerun and accept. You fixed a tool call bug in your Agent and want to confirm it is really fixed. The full eval suite takes an hour and costs tens of dollars, but this change only affects a few of the evals. NiceEval remembers each eval's last result. When you run the same Experiment again, evals that were not affected by this change and already have a conclusion from last time reuse their old results directly, and the rest run again. You do not need to pick evals by hand. ## Look at the plan before running Add `--dry`, and NiceEval only lists the plan, without calling the model or creating a Sandbox: ```sh theme={null} pnpm exec niceeval exp checkout --dry ``` ```text theme={null} PLAN checkout cart/add-item ordinal 0 reuse @1K1P0VJAPVJ12 checkout cart/add-item ordinal 1 execute: input-identity-changed ``` Each line is one Attempt. After `reuse` is the locator of the old result to reuse; after `execute` is the reason for rerunning. The `input-identity-changed` on the second line above means this eval's input changed, for example you changed the eval, the Fixture, or the model. When the result differs from what you expected, look at the reason first. For example, if you only changed a group name in the report but see the whole batch as `execute`, some input that affects results changed along with it. ## Which results are reused An old Attempt is reused automatically only when all of the following hold: * It already has a clear conclusion, `passed` or `failed`. `errored` means it did not complete last time, so it always reruns. * The eval, Fixture, model, Agent, scoring criteria, and run conditions are all the same as this time. * Its duration did not exceed this run's `timeout`. * This run's `--rerun` did not ask to rerun it. * This run did not request real execution with `--keep-sandbox`. Changing only the Experiment's `description` or `labels` does not invalidate old results. Eval files are different: when the file content changes, that eval reruns. ## Control the rerun scope ```sh theme={null} # Default: reuse eligible passed and failed results pnpm exec niceeval exp checkout # Reverify after a bug fix: rerun what failed last time, keep reusing what passed pnpm exec niceeval exp checkout --rerun # The model provider updated its service but local files did not change: rerun everything pnpm exec niceeval exp checkout --rerun all ``` A bare `--rerun` is the same as `--rerun failed`, and evals that already passed do not rerun. To redo the passed ones too, you must write `--rerun all`. `--rerun` only affects this run and does not change existing results. ## Confirm old results still hold under a new configuration Sometimes a change makes NiceEval decide that old results cannot be reused, but you know they are actually unaffected. For example, you added a logging switch to the Agent, and this eval's behavior did not change at all. NiceEval does not guess for you; you need to accept them explicitly: ```sh theme={null} pnpm exec niceeval accept @1K1P0VJAPVJ12 ``` `accept` creates a new Run that references this old Attempt, and the original Attempt stays unchanged. To accept several, put all the locators in one command; NiceEval checks every input first and never accepts only half of them. For how to get candidate locators and what to check before accepting, see [Review and Accept Historical Attempts](/docs/tutorials/accept-results). ## See the results after the run When the run ends, the terminal's `NEXT` panel lists the Runs created this time, with two commands to view each in the terminal and in the browser: ```sh theme={null} pnpm exec niceeval show --run ``` Both reused and rerun evals appear in this Run, and the pass rate is computed over the complete batch. Do not use `show --experiment` to view results before `exp` has finished; at that point there may be no published results yet. For the full ways to view results, see [View Run Results](/docs/tutorials/viewing-results). For how reuse and rerun decisions work, see [Runner](/docs/explanation/runner). # Sandbox Agent: Evaluate Claude Code, Codex, and bub Source: https://niceeval.com/docs/tutorials/sandbox-agent Put Coding Agents such as Claude Code, Codex, and bub into a Docker or cloud Sandbox, give them a real repository and task, then score with file changes and project tests. You wrote a Skill for Claude Code and want to know whether the Agent really fixes bugs more accurately with it installed. This kind of eval cannot just look at a reply: the Agent reads code, edits files, and runs commands, and the result shows up in the repository. Sandbox Agents are built for this. NiceEval first creates an isolated Sandbox, puts your project in it, then starts the Coding Agent's CLI inside and hands it the task. The Agent can freely edit files and run commands without touching your machine. Afterwards, NiceEval collects the conversation record, file changes, and test results for the eval to score. ## Built-in Sandbox Agents These three Coding Agents work out of the box; you only need the matching API key: Runs the Anthropic Claude Code CLI; requires `ANTHROPIC_API_KEY`. Runs the OpenAI Codex CLI; requires `CODEX_API_KEY`. Runs the bub coding agent; requires `BUB_API_KEY` and `BUB_API_BASE`. ## Run a built-in Agent First get a built-in Agent running in local Docker. Set `sandbox` on the Experiment (or the eval) to tell [NiceEval](https://niceeval.com/) where to create the Sandbox: ```ts theme={null} // experiments/local.ts import { defineExperiment } from "niceeval"; import { claudeCodeAgent } from "niceeval/adapter"; import { dockerSandbox } from "niceeval/sandbox"; export default defineExperiment({ agent: claudeCodeAgent(), model: "claude-sonnet-4-6", sandbox: dockerSandbox({ source: { type: "image", image: "node:24-slim" } }), }); ``` ```shell theme={null} export ANTHROPIC_API_KEY=sk-ant-... pnpm exec niceeval exp local fixtures/button pnpm exec niceeval exp local fixtures/button --attempts 10 ``` The second command runs the same eval 10 times. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. The Sandbox can only be written in the Experiment or the eval. There is no matching CLI flag and no project-level default Provider. When neither place specifies a Sandbox image or template, NiceEval reports an error before creating any Sandbox. When running Coding Agents in the cloud, you can use the E2B public templates NiceEval publishes directly, saving the time of installing the CLI for every Attempt: ```ts theme={null} import { codexAgent } from "niceeval/adapter"; import { e2bSandbox } from "niceeval/sandbox"; import { NICEEVAL_CODEX_E2B_TEMPLATE } from "niceeval/sandbox/e2b-template"; export default defineExperiment({ agent: codexAgent(), model: "gpt-5.4", sandbox: e2bSandbox({ template: NICEEVAL_CODEX_E2B_TEMPLATE }), }); ``` Claude Code uses `NICEEVAL_CLAUDE_CODE_E2B_TEMPLATE`, and Bub uses `NICEEVAL_BUB_E2B_TEMPLATE`. The constants' values are complete template references with pinned versions, and the version updates with the Agent version in the template, so you do not need to track it yourself. To add system packages, binaries, or model caches to a template, see [Prebuilt Sandbox Environments · Continue building from the official baseline](/docs/tutorials/prebuilt-sandbox#continue-building-from-the-official-baseline). The built-in Agents exported from `niceeval/adapter` are factory functions. To configure auth, proxies, MCP, or GitHub Skills, write them in the factory parameters. The model still goes in the Experiment's `model` field, and the Sandbox Provider still goes in the `sandbox` field: ```ts theme={null} import { defineExperiment } from "niceeval"; import { claudeCodeAgent } from "niceeval/adapter"; import { dockerSandbox } from "niceeval/sandbox"; export default defineExperiment({ agent: claudeCodeAgent({ apiKey: process.env.ANTHROPIC_API_KEY, baseUrl: process.env.ANTHROPIC_BASE_URL, maxTurns: 8, mcpServers: [ { name: "browser", command: "npx", args: ["-y", "@anthropic/mcp-browser"], }, ], skills: [{ kind: "repo", source: "Effect-TS/skills", ref: "8f3c1a2" }], }), model: "claude-sonnet-4-6", sandbox: dockerSandbox({ source: { type: "image", image: "node:24-slim" } }), }); ``` ## Agent environment variables Before running, confirm these variables are set. Without them, the Agent cannot call the model in the Sandbox. | Agent | Required variables | | - | - | | `claude-code` | `ANTHROPIC_API_KEY` | | `codex` | `CODEX_API_KEY` | | `bub` | `BUB_API_KEY`, `BUB_API_BASE` | These are each Agent's official variable names. When CI stores only one set of shared credentials, pass them explicitly in the Experiment with `apiKey`, `baseUrl`, or `apiBase`. This does not change the variable names the Adapter reads by default. ## What one Attempt does, in order When debugging, first know where each step happens. An Attempt runs in this order: ```text theme={null} Create the Sandbox → Run the Sandbox's setup lifecycle → Run the eval's setup lifecycle and prepare the Fixture → Check the Agent CLI version; install and recheck when needed → Run the Adapter's setup, writing auth and run configuration → Run test(t), writing starting files → The Adapter calls the Agent, recording file changes in the meantime → Run verification commands, finish scoring and the verdict → Run the Adapter's and Sandbox's teardown lifecycle → Stop the Sandbox ``` Starting files and verification commands are both written in `test(t)`. While the Agent runs, it can only see the files you have already written into the Sandbox. File assertions such as `t.sandbox.fileChanged()` only look at **files the Agent changed during `t.send()`**. NiceEval records the workspace state before and after each `t.send()`, and the changes in between are attributed to the Agent. Starting files you uploaded and verification files written after `t.send()` do not count, so `fileChanged("src/app.ts")` passes only when the Agent really changed that file. ## Make file changes recordable When a file assertion fails unexpectedly, or `niceeval show @ --diff` shows no changes, go through the points below first. NiceEval creates a separate private Git repository in the Sandbox and records the workspace state before and after each `t.send()`. This requires: * **`git` and a POSIX shell in the Sandbox.** The default images of the built-in Providers have them; confirm it yourself for custom images. Whether the project under test is itself a Git repository does not matter: the private repository lives outside the working directory, does not touch your `.git`, and the Agent cannot see it. * **Files written in the working directory.** Only files under `sandbox.workdir` are recorded. Omit `targetDir` / `cwd` when writing files; when you need an absolute path, read `sandbox.workdir` instead of hard-coding `/workspace`. Files written outside the working directory are invisible to the Agent and cannot be recorded. * **No second Git repository in the working directory.** Put the project under test directly at the root of the working directory. When the working directory contains a submodule or another cloned repository, the Attempt reports an execution error listing the paths, because file changes in that repository would be silently missed. For a repository that really does not take part in scoring, exclude it entirely with `diff.ignore`. * **Changes happen during `t.send()`.** Files written after the last `t.send()` count as your verification files, not as the Agent's changes. `.git`, `node_modules`, `__pycache__`, Python virtual environments, common build outputs, and package manager caches are not recorded by default; otherwise a single `npm install` could produce tens of thousands of files. To adjust this, write the eval's `diff` field: ```typescript theme={null} export default defineEval({ diff: { ignore: ["fixtures/**"], // Agent changes here do not count as its changes include: ["node_modules/some-pkg/**"], // Patched dependencies should count }, async test(t) { /* ... */ }, }); ``` Rules are written like gitignore, counted from the working directory root: entries without `/` match same-named items at any depth, entries with `/` match from the working directory root, and a trailing `/` means a directory. The project's own `.gitignore` takes no part, so files the project ignores are still recorded. The exclusion list is fixed at the first recording, and later changes the Agent makes to `.gitignore` do not affect it. Before and after each `t.send()`, at most 100,000 paths and 256 MiB of deduplicated text are scanned. At most 10,000 changes are kept; beyond that, only a fixed leading portion is kept and marked `partial` (reason `collection-cap-reached`). Only exceeding the scan limit reports an execution error; it never pretends "no files changed" and lets a file assertion pass wrongly. When the workspace is very large, narrow the scope with `diff.include` or `diff.ignore`. Experiments, eval groups, evals, and Sandbox Agents can all add command-only `.before()` preparation steps. When the Agent writes its own `.env`, put that step in the Agent's `sandbox` field with a larger `changeFrequency`, so fixed tools and Fixtures come first. For how to write them and the caching rules, see [Sandbox · Prepare the Sandbox in order](/docs/tutorials/sandbox-providers#prepare-the-sandbox-in-order). ## Custom Sandbox Agents To evaluate a Coding Agent beyond the built-in ones, write your own with `defineSandboxAgent`. The Agent below runs the `my-agent` CLI in the Sandbox, reads back the conversation record it writes, and translates it into events: ```ts theme={null} import { completeEvidenceCoverage, createNpmCliInstaller, defineSandboxAgent, agentBin, } from "niceeval/adapter"; const { ensure, installer } = createNpmCliInstaller({ identity: { agent: "my-agent", version: "1.0.0", revision: "1" }, packageName: "my-agent", bin: "my-agent", }); export default defineSandboxAgent({ name: "my-agent", evidenceCoverage: completeEvidenceCoverage, ensure, installers: [installer], async send(input, ctx) { ctx.progress({ message: "Running my-agent CLI" }); await ctx.sandbox.runCommand("sh", [ "-lc", `exec ${agentBin("my-agent")} run "$1" --json-out agent-events.json`, "my-agent", input.text, ]); const transcript = await ctx.sandbox.readText("agent-events.json"); if (transcript.trim() === "") { ctx.diagnostic({ code: "empty-transcript", level: "warning", message: "my-agent wrote no transcript; tool assertions may lack evidence", }); } return { status: "completed", events: parseTranscript(transcript), }; }, }); ``` `createNpmCliInstaller` downloads the npm package on the host, transfers it into the Sandbox, then installs it with npm. So the Sandbox needs Node.js and npm; only a `platformPackage` that bundles its own runtime does not. For the full steps, see [Install the CLI for a Custom Sandbox Agent](/docs/tutorials/install-custom-sandbox-agent). `setup`, each `send`, and `teardown` each have their own feedback methods; use them to tell you where the run is and what went wrong: * `ctx.progress({ message, current?, total? })` shows what is happening now, suited to steps such as installing the CLI, running a turn, or reading the conversation record. Do not call it for every token or every JSONL line. * `ctx.diagnostic({ code, level, message, data?, dedupeKey? })` leaves a problem to review afterwards, such as incomplete output format, a missing conversation record, or a cleanup failure. It is shown in the terminal and saved with this Attempt's results. * When it cannot continue, throw directly. NiceEval saves the failing phase, error code, error message, cause, and stack, and judges this Attempt `errored`. Do not call `console.log/error` or write to `process.stdout/stderr` directly in the Adapter. They scramble the terminal's run panel and also put CI logs out of order. When a Sandbox or Adapter error occurs during the run, the terminal gives this Attempt's locator: ```text theme={null} ✗ @5TB8167MXJ30SYZCNAVRHPQ4D2 fixtures/button [local] errored · agent setup agent-install-failed: npm install my-agent exited with code 1 Inspect: niceeval show @5TB8167MXJ30SYZCNAVRHPQ4D2 ``` ```sh theme={null} pnpm exec niceeval show @5TB8167MXJ30SYZCNAVRHPQ4D2 pnpm exec niceeval show @5TB8167MXJ30SYZCNAVRHPQ4D2 --timing ``` The first shows the full error, diagnostics, and completed phases. The second shows each phase's duration, so you can see which step errored or timed out. When a script or CI needs the same information, use `pnpm --silent exec niceeval query discover` to find the Attempt trace request, then run it with `query run`. When there are many steps, once the phase tree exceeds 80 detail nodes, only failed, slow, and the first and last few items are kept, with a note on how many were omitted. To check every item, read the full trace with `query`. A Sandbox creation failure can happen before trace collection starts, so the error does not depend on the trace and `show @` can always show it. ## Pass the model and flags to the CLI The `model` and `flags` written in the Experiment appear on `ctx.model` and `ctx.flags`. The Adapter converts them into CLI arguments or a request body, and only then does the Agent really switch models or configuration. ## Use a custom Agent in an Experiment Put your Agent into an Experiment the same way as a built-in Agent: ```ts theme={null} import { defineExperiment } from "niceeval"; import { dockerSandbox } from "niceeval/sandbox"; import myAgent from "./agents/my-agent"; export default defineExperiment({ agent: myAgent, model: "claude-sonnet-4-6", sandbox: dockerSandbox({ source: { type: "image", image: "node:24-slim" } }), attempts: 3, }); ``` ```shell theme={null} pnpm exec niceeval exp ``` `attempts: 3` runs each eval 3 times. A Coding Agent's approach varies a lot from run to run, so one run's result does not say much. Each Agent's distinct behavior is written in its own Adapter. Do not branch on Agent names anywhere else. # Choose and Configure a Sandbox: Docker, Vercel, or E2B Source: https://niceeval.com/docs/tutorials/sandbox-providers Choose an isolated runtime for a Coding Agent: local Docker, Vercel, or E2B. Prepare the environment in order, and use prebuilt images and caches so each eval starts faster. A Coding Agent needs an environment where it can freely change files and run commands without touching your machine. That isolated environment is the Sandbox, created and destroyed by a Provider. NiceEval provides three official Providers: local Docker, Vercel, and E2B. Docker is the most convenient for local debugging; when you need heavy parallelism, or your machine cannot keep up, switch to Vercel or E2B in the cloud. Whichever Provider you use, evals and Adapters see the same `Sandbox` interface. Switching Providers changes one line in the Experiment, and the evals stay unchanged. ## Choose a Provider Start by choosing based on where you run evals: | Provider | Suits | What you need | | - | - | - | | Docker | Local development and standard CI. Simple, controllable, no cloud dependency | Local Docker. Machine resources are limited, and cold starts and dependency installs can be slow | | Vercel Sandbox | When you need cloud isolation, more resources, or a more stable environment | A Vercel token or OIDC configuration | | E2B | When you need heavy parallelism, or want to use a ready-made Agent template directly | An E2B account and `e2b auth login` | Choose one in the Experiment's `sandbox` field: ```ts theme={null} // experiments/local.ts import { defineExperiment } from "niceeval"; import { dockerSandbox } from "niceeval/sandbox"; export default defineExperiment({ agent: myCodingAgent, model: "claude-sonnet-4-6", sandbox: dockerSandbox({ source: { type: "image", image: "node:24-slim" } }), // or vercelSandbox({ snapshotId }) / e2bSandbox({ template }) }); ``` When neither the eval nor the Experiment chooses a Sandbox, the run reports an error before creating any Sandbox; NiceEval never guesses a Provider for you. The SDKs of the three built-in Providers are not installed with NiceEval. Install only the one you use, so unused dependencies (and their native build scripts) never enter your project: | Provider | Install command | | - | - | | `dockerSandbox({ source: { type: "image", image: "node:24-slim" } })` | `pnpm add dockerode @types/dockerode` | | `vercelSandbox({ snapshotId })` | `pnpm add @vercel/sandbox` | | `e2bSandbox({ template })` | `pnpm add e2b` | If it is missing, creating the Sandbox reports an error with the install command, for example `Docker sandbox requires 'dockerode'. Install it with: pnpm add dockerode @types/dockerode`. Install it as shown and run again. ### Docker Compose: `workspaceService` names the main Sandbox When one eval needs more than one container (for example an application container plus a database), declare the whole Compose environment with `dockerComposeSandbox`: ```ts theme={null} import { dockerComposeSandbox } from "niceeval/sandbox"; sandbox: dockerComposeSandbox({ file: new URL("docker-compose.yaml", import.meta.url), workspaceService: "client", // The main Sandbox }) ``` `workspaceService` names which service in the Compose file is the main Sandbox. The Agent, the commands and file operations of `t.sandbox`, `workdir`, and file change records all apply only to this one container. Other services (databases, mock servers, and so on) are only supporting resources: you cannot `runCommand` on them, upload to them, or read and write their files, and their file changes are not visible. To use them, go over the network, for example reaching `db:5432` from the `client` container through Compose's built-in DNS. ### Let the Agent run Docker itself When the Agent needs to run `docker build`, `docker run`, or `docker compose`, you can mount an existing Unix socket from the host, or choose raw privileged or managed rootless Docker-in-Docker. For when each mode fits, the Dockerfile, and the full configuration, see [Let the Sandbox Use Docker](/docs/tutorials/docker-in-docker). ## Work with the Sandbox in Adapters and evals Whichever Provider you use, Adapters and evals get the same `Sandbox` interface. Common operations: | Method | Purpose | | - | - | | `runCommand(cmd, args)` | Run a command | | `runCommandOrThrow(cmd, args)` | Run a command and throw on a non-zero exit | | `runShell(script)` | Run a shell script | | `runShellOrThrow(script)` | Run a shell script and throw on a non-zero exit | | `readText(path)` / `readBytes(path)` | Read a text or binary file | | `writeText(path, content)` / `writeBytes(path, content)` | Write a text or binary file | | `pathExists(path)` | Check whether a path exists | | `uploadFile(source, target)` / `uploadDirectory(source, target?)` | Upload a file or directory | | `workdir` | The real working directory in the Provider. Omitted `cwd` / `targetDir` point here | | `runCommand(..., { cwd })` | Run one command in another directory. Relative paths resolve against `workdir` | | `stop()` | Destroy the environment | The error messages thrown by the two `OrThrow` methods include the last few lines of stderr, already cleaned, redacted, and truncated. When stderr is empty, stdout is used instead. When debugging, read this summary first, and read `SandboxCommandExitError.result` when you need the full output. ## Prepare the Sandbox in order Before the Agent starts working, the Sandbox usually needs tools installed, configuration written, and materials in place. Write these preparation steps with `.before()`, and cleanup that runs whether the run succeeds or fails with `.after()`. Every Provider function returns a `SandboxLayer` that you can keep appending steps to, and appending never modifies the original value: ```ts theme={null} import { changeFrequency, dockerSandbox, shell, writeText, } from "niceeval/sandbox"; const sandbox = dockerSandbox({ source: { type: "image", image: "node:24-slim" }, }) .before(shell({ id: "install-tools", command: "corepack enable && pnpm install --frozen-lockfile", changeFrequency: changeFrequency.rare, })) .before(writeText({ id: "experiment-env", path: ".env", text: "FEATURE_X=1\n", changeFrequency: changeFrequency.frequent, })); ``` `changeFrequency` decides the order of steps: steps with small values that rarely change come first, and steps with large values that change often come later. That way, when only a later step changes, earlier steps can reuse the last result directly. It is an ordinary non-negative number; the built-in constants are only for readability: ```ts theme={null} changeFrequency.rare // 10 changeFrequency.normal // 100 changeFrequency.frequent // 1000 ``` The preparation steps of the Experiment, eval group, eval, and Sandbox Agent are placed in one order: explicit `dependsOn` is satisfied first, then steps run from small to large values. So a rarely changing step in an eval group can come before a frequently changing step in the Experiment. At run time, each preparation step appears in the progress display without leaking content: * `shell()` shows the command you wrote, not the wrapping `/bin/sh -lc`; `command()` and `exec` show the executable and arguments. * `writeText()` / `writeBytes()` show only the target path, byte count, and digest, not the body. * Uploads show only the digest, target path, and byte count, not the source path on the host. * Git checkout addresses have user info, query, and fragment removed; environment variables list only names. Very short progress updates may be merged in the display. To reliably see fine-grained progress for a long script, split it into several smaller steps, each running long enough on its own, or report progress yourself. NiceEval does not track steps inside shell scripts. Dynamic callbacks report their own progress with `context.progress()`. ### Declare preparation cache coverage On supported Providers, unchanged preparation steps can be reused on the next run without executing again. For this, each `.before(action)` must truthfully state which parts of the Sandbox it changes: * Omitting `cache.state` equals `sandboxState.all`: all of this step's changes must be saveable and restorable. It is not a switch that lets the Provider pick some directories to cache. * Write `sandboxState.dockerData` only when all of the step's changes are limited to `/var/lib/docker` in the Sandbox, which has stopped being written. For a complete Docker profile example, see [Let the Sandbox Use Docker](/docs/tutorials/docker-in-docker#managed-rootless-dind). `cache.state` can be written at the step definition or at a single call site, but only in one place, and a call site cannot override the definition. The step's ID, inputs, expanded command, declared scope, and content digest are all compared automatically, and when any of them changes, the step runs again. `cache.fingerprint` only supplements version information NiceEval cannot see; it does not replace the automatic comparison. Providers can save different scopes: | Provider situation | Scope it can save | Result | | - | - | - | | Local single-container Docker, with all changes in the outer container's writable filesystem | `all` | The longest unchanged run of steps from the start is reused directly | | A Docker profile configured with a separate, pre-allocated, fixed-size storage slot and seed data | `dockerData` | Only the run of steps from the start that consecutively declare `dockerData` is reused | | A Docker profile with shared loop or project-quota storage | None | Shows `Unsupported`; steps really execute | | Docker Compose, E2B, Vercel, or a custom Provider | None | Shows `Unsupported`; steps really execute | Reuse can only continue consecutively from the first step. At the first step that cannot be saved, such as a default `all` the Provider does not support, an `opaque` callback, or a scope the Provider does not support, this step and every step after it really execute. Later steps cannot be skipped even if they declare `dockerData`, because the earlier state they depend on was not saved. On local single-container Docker, when only the final `.env` step changes, the fixed Git checkout, directory upload, and tool installation before it do not rerun. Each Attempt starts from its own independent copy of the cached result and does not inherit changes left by the previous Agent. To confirm which steps will be reused, run `pnpm exec niceeval debug ` to see the plan; see [Check the Sandbox Plan Before Running](/docs/tutorials/debug-lifecycle-plan). ### Define reusable preparation steps The built-in `shell()`, `writeText()`, `uploadDirectory()`, and `gitCheckout()` are all `SandboxAction`s. When the same kind of preparation step is used in several places, you can define your own step with the same mechanism: ```ts theme={null} import { Schema } from "effect"; import { defineSandboxAction, sandboxStep, } from "niceeval/sandbox"; const installTool = defineSandboxAction({ id: "@acme/install-tool", input: Schema.Struct({ version: Schema.String }), cache: { fingerprint: "2" }, steps: ({ version }) => [ sandboxStep.exec({ executable: "tool", args: ["install", version], }), ] as const, }); const sandbox = e2bSandbox({ template: "niceeval-agents" }) .before(installTool( { version: "1.4.0" }, { changeFrequency: 20 }, )); ``` Custom steps can only combine fixed operations NiceEval provides, and cannot access the running `Sandbox`. The rules above about cache scope and automatic comparison apply as well. ### Writing, uploading, and Git checkout These built-in functions can be passed directly to `.before()`: | Content source | Use | | - | - | | Existing text or bytes | `writeText()` / `writeBytes()` | | Fixed files or directories next to the definition file | `uploadFile()` / `uploadDirectory()` | | A fixed commit of a public Git repository | `gitCheckout()` | | Commands as shell or argument lists | `shell()` / `command()` | `uploadFile()` and `uploadDirectory()` take addresses such as `new URL("fixture/", import.meta.url)`, not relative paths that change with the current directory. File contents, permissions, and directory structure are compared automatically. `gitCheckout()` only accepts HTTPS addresses without credentials and full commit IDs. Do not put hidden content that should only be provided after the Agent returns into `.before()`. First get a handle with `sandboxContent.file()` or `sandboxContent.directory()`, then call `t.sandbox.upload(handle, targetPath)` in `test(t)`. The handle carries only a content digest and does not expose host paths. ### Dynamic callbacks and cleanup For branches that can only be decided at run time, write `.before(async (sandbox, context) => ...)` directly. The callback really executes every time, and the steps after it no longer reuse the cache. After successfully acquiring a resource, register its release right away with `context.onCleanup()`. The closure can hold the handle obtained this time directly, so you do not need another callback that guesses the resource's state: ```ts theme={null} const sandbox = e2bSandbox({ template: "niceeval-agents" }) .before(async (sandbox, context) => { const forwarder = await startLogForwarder(sandbox, { signal: context.signal, }); context.onCleanup(async () => { await forwarder.stop(); }); }); ``` `sandbox.sandboxId` in the callback is the current Sandbox's native ID on the Provider side. Normally treat it only as a value for correlation, and do not parse it. Only when this layer is explicitly bound to a particular Provider should your own trusted code hand it to the matching SDK to create helper resources for this Attempt. These helper resources are not managed by NiceEval and are not cached; after creating them, you must immediately register a cleanup that can safely run more than once. Before the first callback starts, NiceEval may swap the underlying Sandbox instance; once a callback has started, this Attempt's cleanup and `.after()` all see the same ID. Cleanup that must run whether the run succeeds or fails, and can safely run more than once, goes in `.after()`. All registered cleanups run in reverse registration order, and a failing earlier cleanup does not stop later ones: ```ts theme={null} const sandbox = dockerSandbox({ source: { type: "image", image: "node:24-slim" }, }) .before(shell({ id: "start-helper", command: "./helper start", })) .after(shell.after({ id: "stop-helper", command: "./helper stop || true", })); ``` `context.progress()` in a callback only updates the current Attempt's temporary status, and `context.diagnostic()` saves a problem to review afterwards. When the environment cannot continue, throw directly; NiceEval records this Attempt as an environment error, not as the Agent getting the task wrong. A `defineAgent` without a Sandbox has no preparation steps. A Sandbox Agent can add command steps in the form of `sandboxLayer()` in its own `sandbox` field, but cannot choose or replace the Provider and template. ## Prebuilt environments When the same content is downloaded or installed for every Attempt, and it is stable, expensive, or large, bake it into a prebuilt environment and let Attempts start directly from the installed environment. The three Providers are written the same way: ```ts theme={null} dockerSandbox({ source: { type: "image", image: "my-evals:node24" } }) vercelSandbox({ snapshotId: "snap_abc123" }) e2bSandbox({ template: "my-evals" }) ``` For how to derive images, snapshots, and templates from official baselines, and how to carry caches between Attempts with run-time checkpoints, see [Prebuilt Sandbox Environments](/docs/tutorials/prebuilt-sandbox). ## Retries for transient errors When the built-in Providers create a Sandbox and hit transient failures such as rate limits, `fetch failed`, connection resets, 5xx, or temporary network unreachability, they retry automatically with exponential backoff. Configuration errors such as a missing template or missing credentials fail on the first try. When retries run out, the Attempt is recorded as `errored`. The `create` of a custom `defineSandbox` Provider is your own function, and NiceEval does not retry it for you. `readText`, `readBytes`, `downloadFile`, `uploadFile`, file writes, and directory uploads retry a limited number of times on transient transport errors such as 429, 5xx, `fetch failed`, and connection resets. Missing files, permission errors, cancellation, and a terminated Sandbox are not retried. `runCommand` and `runShell` do not retry automatically, because a command may already have had side effects. Retry yourself in a hook or eval only when you have confirmed a command is safe to repeat. ## Custom Providers To connect another service, use `defineSandbox`. You are responsible for isolating the filesystem, processes, network, and credentials yourself; NiceEval does not add these security boundaries automatically to an arbitrary `Sandbox` implementation. The `feedback` that `create` receives reports status during creation, such as allocating an instance, pulling an image, or restoring a snapshot: ```ts theme={null} import { defineSandbox } from "niceeval/sandbox"; export default defineSandbox({ name: "modal", recommendedConcurrency: 8, async create({ timeout, runtime, feedback }) { feedback.progress({ message: "Allocating Modal Sandbox" }); const instance = await allocateModal({ timeout, runtime }); if (instance.usedFallbackRegion) { feedback.diagnostic({ code: "modal-fallback-region", level: "warning", message: `Primary region unavailable, using ${instance.region}`, data: { region: instance.region }, }); } return new ModalSandbox(instance); }, }); ``` The return value only needs to implement the `Sandbox` interface. `recommendedConcurrency` is the default concurrency when nothing else is set. Do not write a Provider SDK's raw logs directly to the host process's `stdout` / `stderr`: use `feedback.progress` for temporary status, `feedback.diagnostic` for problems that should be kept, and throw when the environment cannot be created. That way the terminal's progress panel is not scrambled by logs, and CI has only one ordered output. ## Permissions and execution identity Providers differ in their limits on the executing user, network, filesystem, and process lifecycle. When writing a Fixture, avoid depending on the host environment as much as possible, and put dependencies in `package.json` or the Fixture's preparation steps. ## Performance tips * Bake stable heavy dependencies into an image, template, or snapshot, or write them as low-frequency `.before()` steps. * Reduce the size of the Fixture's dependencies. * Use small, explicit caches or pre-checks for dynamic content. * Control `maxConcurrency` (the Experiment field or `--max-concurrency`) to avoid exhausting local Docker resources. * Split slow checks: keep the ones that must pass as gates, and use `.optional()` for the nice-to-haves. ### Reuse Provider startup across Attempts When Sandbox startup time dominates, declare `sandboxReuse: true` in the Experiment. Several Attempts share one Sandbox in turn: each Sandbox is created only once, each Attempt still runs its own `.before()` / `.after()`, and the working directory is reset between evals. On Docker, unchanged preparation steps can also be reused directly. ```ts theme={null} export default defineExperiment({ agent: codexAgent(), sandbox: e2bSandbox({ template: "acme-evals", lifetimeMs: 60 * 60_000 }), sandboxReuse: true, maxConcurrency: 3, }); ``` Reuse is written in the Experiment file, not a command-line switch: the same experiment cannot turn it on or off temporarily. When you need fresh Sandboxes for a comparison, write a separate experiment that does not declare reuse. For the costs, how many times each lifecycle layer runs, and the requirements preparation code must meet, see [Reuse Sandboxes](/docs/tutorials/sandbox-reuse). # Reuse Sandboxes Across a Batch of Evals Source: https://niceeval.com/docs/tutorials/sandbox-reuse When creating a new Sandbox for every Attempt is too slow, use sandboxReuse to let a batch of Attempts share the same Sandbox in turn. Learn what it saves, what it leaves behind, and how to write preparation code that replays safely. While smoke-testing a batch of evals locally, you notice most of the time goes to waiting for Sandboxes to start and dependencies to install; the Agent's real work is only a small part. Declare `sandboxReuse: true` in the Experiment, and several Attempts share the same Sandbox in turn. Each Sandbox is created only once, and between evals NiceEval restores the working directory to its starting state. Each Attempt still really executes, and still has its own `.before()` / `.after()` preparation and cleanup. Unchanged Docker preparation steps are reused directly instead of executing again. This page covers Experiment-level `sandboxReuse`. When only a few groups of compatible evals each need their own reuse boundary, use [eval groups](/docs/tutorials/eval-groups): reuse runs serially within a group, while different groups keep running in parallel. An Experiment that selects eval groups cannot also declare `sandboxReuse: true`. The reset between evals does not reset the whole Sandbox. NiceEval only restores the working directory `workdir`. State outside `workdir` such as `/opt`, `$HOME`, and `/tmp`, global installs, package caches, and background processes all remain. For large persistent build outputs and caches, you decide the capacity limit, alert threshold, and how to clean up or rotate them; when it cannot safely continue, you also need a way to replace this Sandbox. Before turning it on, understand the costs: * **Reuse of last results still applies.** When an eval's configuration is the same as last time and it already has `passed` or `failed`, the old result is reused directly and no Sandbox is created for it. Only Attempts that really need to run this time enter the shared Sandbox. * **NiceEval cannot see changes to callback function bodies.** When a Sandbox Plugin's name, instance, `behaviorRevision`, declared `identity`, or preparation steps change, old results are invalidated and rerun. When you only changed code inside a callback, update `behaviorRevision` or `identity` as well, or use `--rerun all`. * **Interruption does not roll back external state.** Resuming is equivalent to running straight through only when state shared across Attempts can be restored to just after the last completed Attempt. When that is not possible, start over with a clean copy of the state. * **State outside the working directory remains.** `$HOME`, `/tmp`, global installs, and background processes all carry over to the next eval. Preparation code must accept this; see "Write preparation code correctly" below. * It cannot be used together with `--keep-sandbox`. Good fits: smoke-testing a batch of evals locally, running the same eval N times to check stability, and verifying an Adapter can connect. You want speed while still keeping result reuse and `--rerun`. ## Turn it on: three fields in the Experiment ```typescript theme={null} import { defineExperiment } from "niceeval"; import { e2bSandbox } from "niceeval/sandbox"; import { codexAgent } from "niceeval/adapter"; export default defineExperiment({ evals: ["react-datepicker/"], agent: codexAgent(), sandbox: e2bSandbox({ template: "acme-evals", lifetimeMs: 60 * 60_000, // ① Sandbox lifetime: required with reuse, or it errors before dispatch }), sandboxReuse: true, // ② Declare reuse maxConcurrency: 3, // ③ How many Sandbox lanes run in parallel; each lane takes several Attempts in turn timeoutMs: 20 * 60_000, }); ``` `timeoutMs` and `lifetimeMs` are two clocks: the former limits how long one Attempt runs, and the latter limits how long one Sandbox lives. To let a Sandbox live longer, raise `lifetimeMs`. Do not raise `timeoutMs`; that also loosens the protection against a stuck Agent. The upper limit of `lifetimeMs` depends on your Provider account tier; for example, the E2B free tier is 1 hour. Beyond the limit, creating the Sandbox reports the Provider's raw error. Run with `pnpm exec niceeval exp `. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. ## Sandboxes are not shared across terminals Sandboxes are reused only within one `niceeval exp` command. When two terminals run the same experiment at the same time, each has its own Run and Sandboxes, and NiceEval never hands a Sandbox running in one process to another process. Both sides can write results into the same project's `.niceeval/record.sqlite`; you do not need to split the result file for this. To copy or archive `.niceeval/record.sqlite`, wait until the command finishes successfully. A SQLite file in use by a run cannot be treated as a portable snapshot. If each Sandbox keeps only its own temporary state, the two sides can run different evals at the same time. When a dynamic `.before()` callback restores and saves the same external checkpoint, declare a stable key without secrets on the Experiment: ```typescript theme={null} export default defineExperiment({ sandbox: e2bSandbox({ template: "mempal" }) .before(async (sandbox, context) => { await restoreMempal(sandbox); context.onCleanup(async (sandbox) => { await saveMempal(sandbox); }); }), sandboxReuse: true, maxConcurrency: 1, sharedState: { key: "mempal/codex/cohort-a" }, }); ``` NiceEval acquires the lease for this key before Experiment `setup` and Sandbox creation, and releases it only after Sandbox cleanup, Provider finalization, and Experiment `teardown` all finish. A run with the same key in another terminal queues and does not create a Sandbox early. After it gets the lease, it continues with its own plan and does not read or reuse the other side's results. The key is also part of the result configuration. A different key means a different copy of the state, so old results do not mix in. The lease only protects the external checkpoint and has nothing to do with the result file; runs that do not use `sharedState` can still write results concurrently. Coordination across machines or different working copies still needs an external distributed lock. ## Lifecycle: what runs how many times | Phase | How many times with reuse | | - | - | | Sandbox create / stop | Once per Sandbox | | Sandbox `.before()` / `.after()` preparation steps and registered cleanups | Once per Attempt that really runs | | Agent-level `setup` / `teardown` | Once per Attempt | | The eval's `setup` / `teardown` and `test(t)` | Once per Attempt | After each Attempt, NiceEval runs `git reset --hard` and `git clean` on `workdir` to restore its starting state, then starts the next. This step only handles `workdir`; `/opt`, `$HOME`, `/tmp`, global installs, package caches, and background processes do not disappear. Either the eval's `teardown` cleans them up itself, or they are state you deliberately leave for the next eval. Large persistent build outputs or caches must not grow unchecked. Give them a capacity limit and an early-warning threshold: record normal size and hit rates with `facts`, warn with `diagnostic` when nearing the risk threshold, and have a way ready to clean up, rotate, or replace this Sandbox. ## Write preparation code correctly: layer by "what it changes with" * **Heavy dependencies every experiment needs** (Agent CLIs, language runtimes): bake them into the Provider's image / template / snapshot, or write them as the lowest-frequency `.before()` preparation steps. * **Preparation shared by the whole batch** (installing toolchains, cloning shared repositories, warming build caches): write them as low-frequency `.before()` preparation steps. On Docker, unchanged preparation steps can be reused directly on the next run. These steps are still passed through once per Attempt; you cannot ask for them to run only once per Sandbox. * **Materials only this eval needs** (its own repository, data, dependencies): put them in the eval's own `setup` or `test(t)`. They run again after each reset, so they must be code that is correct when repeated. * **Background processes started or ports taken**: the eval's `teardown` cleans them up itself; resetting `workdir` does not kill processes. When each eval clones its own repository, use one fixed-name temporary directory for the whole Experiment, such as `.niceeval-task-clone`, and add it to `diff.ignore`. Before cloning, only run `rm -rf .niceeval-task-clone` to clear the previous eval's leftovers. Never delete the `.git` at the root of `workdir`: the reset between evals depends on it, and it stays for the whole run. ## Idempotence is a hard requirement: one bad, one good Preparation code at both the Sandbox level and the eval level may hit half-finished state left by a previous run. Compare two ways of writing it: ```bash theme={null} # Bad: skip the whole block when a probe succeeds. When rustup is half-installed (cargo present, no default toolchain set), # the probe passes, installation is skipped, the next cargo build fails, and the whole Attempt errors. if ! command -v cargo; then curl https://sh.rustup.rs | sh; fi # Good: declare the target state directly; repeating it any number of times gives the same result. rustup default 1.79.0 npm install # Installs everything per package.json/lockfile and is itself safe to repeat ``` The rule in one sentence: **preparation code describes the target state, not "whether to do it"**. "Skip when the probe succeeds" treats a half-finished state as finished. This kind of problem only shows up with reuse; running locally on its own never reveals it. ## Agent-native Plugins: installation is handled by the Adapter `codexAgent({ plugins: [...] })` and `claudeCodeAgent({ plugins: [...] })` work with `sandboxReuse`. Agent-native Plugins are installed in `$HOME` and carry over to the next eval, but you do not need to handle this: before each Attempt starts, the Adapter reinstalls them according to the `source` and `ref` you declared. Same-named marketplace registrations and plugins left by the previous eval are replaced. When a plugin's own script rewrites the marketplace registration (for example switching to a hosted source), it is restored the same way. This refers to native Plugins in the Agent factory. The top-level `plugins` field of evals, experiments, and eval groups is NiceEval's Plugin; for how the two divide the work, see [Reuse Complete Eval Conditions with Plugins](/docs/tutorials/plugins). Two things are still yours: * **`postSetup` scripts must be safe to repeat.** They run again on the carried-over `$HOME` every Attempt. A script that registers a hook in global configuration must produce the same configuration no matter how many times it runs. Before turning on reuse, run two or three evals through one Sandbox lane first; problems only show up from the second eval onward. * **Where plugin data lives.** The plugin installation directory is reinstalled and overwritten every Attempt. Data that should carry over to the next eval can only be written outside the installation directory. Whether carried-over data affects the next eval is a question your experiment design has to answer. Reinstalling does not save money: plugin installation is paid once per Attempt. What reuse saves is Sandbox creation; unchanged `.before()` preparation steps can also be reused on Docker. When marketplace fetching is too slow, use `sparse` to fetch only the plugin path, or bake the plugin into the template and remove its declaration from `plugins`. The cost is that the installation manifest no longer records the plugin and its resolved version. ## A batch with mixed Sandbox configurations When the selected evals each declare different `template`s, you do not need to split them into several commands. NiceEval checks each combination of eval and Experiment. Attempts with different Sandbox configurations never share a Sandbox, and never share a running Sandbox. The experiment's `maxConcurrency` limits the total number of selected evals running at once. Identical preparation steps can still each reuse their preparation results on Docker. ## A typical rhythm ```sh theme={null} # 1. After changing shared configuration, look at the plan before spending money pnpm exec niceeval exp smoke memory/commit0 --dry # 2. Go through quickly with an experiment that declares sandboxReuse pnpm exec niceeval exp smoke memory/commit0 # 3. One eval fails: rerun it alone with an experiment that does not declare reuse, keeping the scene pnpm exec niceeval exp baseline memory/commit0/tool-first --keep-sandbox # 4. After the fix, verify with an experiment that does not declare reuse to get trustworthy results pnpm exec niceeval exp baseline memory/commit0 ``` Step 3 must switch experiments: `--keep-sandbox` cannot be used with `sandboxReuse`, and a shared Sandbox has been used by the whole batch, so the scene it leaves is not accurate for any single eval. Do the same when something "fails when run together but passes alone"; do not debug inside a batch that shares Sandboxes. For the full process, see [Keep the Sandbox to Debug Problems](/docs/troubleshooting/debug-sandbox). ## When you cannot make it idempotent, use concurrency instead of reuse When preparation code cannot be made safe to repeat, or the eval depends on a fresh `$HOME` (for example the system under test has its own memory or stateful services), do not declare `sandboxReuse`. Concurrency alone is enough for speed: ```typescript theme={null} export default defineExperiment({ // No sandboxReuse: every Attempt uses a fresh Sandbox with no leftovers maxConcurrency: 8, // Still runs in parallel // ... }); ``` The only thing reuse saves beyond plain concurrency is "creating each Sandbox once plus shared preparation". When that time is much smaller than one Attempt's execution time, concurrency already gets almost all of the speedup, without the leftover problems reuse brings. Use concurrency first, measure that shared preparation really dominates, then consider reuse. ## Related reading * [Eval Groups](/docs/tutorials/eval-groups): list group members explicitly; a group shares a Sandbox while groups run in parallel as usual. * [Rerun and Reuse](/docs/tutorials/rerun-and-cache): how last results are reused with Sandbox reuse, and when to use `--rerun`. * [Concurrency and Execution Order](/docs/tutorials/concurrency): how to get the same speedup with concurrency without reuse. * [Sandbox Provider Configuration](/docs/tutorials/sandbox-providers): how to make each Provider's template / image / snapshot. # Confirm What Runs and How Many Times Before Running Source: https://niceeval.com/docs/tutorials/select-and-preview List which Experiments exist, narrow the scope with ID prefixes and tags, then use --dry to see which evals this run executes and how many times, and confirm before spending money. You only wanted to reproduce one failing eval, but moved too fast and ran all 200 evals in the whole experiment group, spending tens of dollars. When the repository has several groups of Experiments and many evals, first confirm what this run will actually execute, then start it for real. Do not guess IDs from file names, and do not launch a batch that may be the wrong selection. ## List runnable Experiments First see which Experiments the project has, and confirm the ID you want: ```sh theme={null} pnpm exec niceeval exp list pnpm exec niceeval exp list compare/codex ``` Each line shows the Experiment ID, description, Agent, model, number of Attempts, number of selected evals, and labels. `exp list` only reads configuration; it does not start Sandboxes, call models, or write results. When a script needs the complete list, add `--json`: ```sh theme={null} pnpm exec niceeval exp list --json > experiments.json ``` ## Run only part of it The first positional argument selects the Experiment and can be an ID, a directory, or a file name prefix. Later arguments narrow further by eval ID prefix: ```sh theme={null} pnpm exec niceeval exp compare/codex memory/commit0 ``` An exact Experiment ID wins over a prefix match. The directory part must match exactly. Eval prefixes can only narrow within the evals this Experiment already selects; they cannot add evals outside its scope. To run only evals with a certain tag, use `--tag`: ```sh theme={null} pnpm exec niceeval exp compare --tag smoke ``` Positional arguments and `--tag` only affect this run. To change which evals an Experiment runs long-term, change its `evals`. ## See the plan with `--dry` Before really running, add `--dry` to the same command: ```sh theme={null} pnpm exec niceeval exp compare/codex memory/commit0 --dry ``` The plan lists each eval, run configuration, and Attempt number, and marks whether each Attempt executes again or reuses the last result. `--dry` does not create a Run, start Sandboxes, call models, or write JUnit. Check line by line: whether the number of evals is right, whether the model is the one you want, how many times each eval runs, and which ones reuse old results. For the rules on reuse and rerun, see [Rerun Only the Evals Affected by a Change](/docs/tutorials/rerun-and-cache). When a script needs to read the plan, add `--json`: ```sh theme={null} pnpm exec niceeval exp compare/codex memory/commit0 --dry --json > plan.json ``` After confirming, drop `--dry` and run the same command. After the run, view results in the terminal with `pnpm exec niceeval show`, or in the browser with `pnpm exec niceeval view`. ## What to do when nothing is selected * When no Experiment is selected, the CLI lists the available directories and suggests running `exp --dry` first. * When an Experiment is selected but not a single eval is, the CLI reports `No evals selected` and does not fall back to running the whole Experiment. * To see the preparation steps and their order in the Sandbox, rather than which evals run, see [Check How the Sandbox Will Be Prepared Before Running](/docs/tutorials/debug-lifecycle-plan). # Configure the Judge and Confirm It Can Really Score Source: https://niceeval.com/docs/tutorials/verify-judge Choose the Provider, model, and API key for the Judge model, then run one real scoring pass with a minimal eval, and confirm the configuration is correct before using it at scale. Questions such as "is this explanation suitable for elementary school students" or "did the answer make up facts" cannot be written as a regex; they go to the Judge model to score. The Judge itself can go wrong too: a wrong API key, a misspelled endpoint, a misspelled model name. If you only find these problems after 200 evals have run, none of the Judge scores along the way can be obtained. So configure it first, then run one real scoring pass with a minimal eval, and use it at scale only after confirming you get a score. ## Configure which service the Judge uses In `niceeval.config.ts`, spell out the service, model, and endpoint with a named Provider. The example below uses an OpenAI-compatible gateway: ```ts title="niceeval.config.ts" theme={null} import { defineConfig } from "niceeval"; import { OpenAIProvider } from "niceeval/judge"; export default defineConfig({ judgeRuntime: OpenAIProvider({ model: "judge-model", baseUrl: "https://gateway.example.com/v1", apiKeyEnv: "JUDGE_GATEWAY_KEY", timeoutMs: 180_000, maxOutputTokens: 512, }), }); ``` `apiKeyEnv` holds only the variable name. Put the key itself in an environment variable, or in the project's `.env`: ```sh theme={null} export JUDGE_GATEWAY_KEY="..." ``` ## Write a minimal eval Write an eval with only one Judge assertion to confirm the configuration can get a score: ```ts title="evals/judge-smoke.eval.ts" theme={null} import { defineEval } from "niceeval"; export default defineEval({ judge: "judge-model", async test(t) { const operation = "Complete the data import"; const turn = await t.send(operation); turn.closeQA("Does this turn clearly report the execution status and data import result?") .gate(0.8) .label("Reports success clearly"); }, }); ``` `turn.closeQA(question)` hands this Turn's full conversation to the Judge, which answers your question and gives a score from 0 to 1. `.gate(0.8)` means this eval fails when the score is below 0.8. When the material is empty, this assertion scores 0 directly without calling the Judge. When the material is incomplete and cannot be judged, the result is `unavailable`, which is not counted as 0. To have the Judge look only at specific messages or tool calls, pass the matching Match to `closeQA`; for the application's own business material, use a custom `MaterialMatch`. See [NiceEval Taste](/docs/tutorials/niceeval-taste) for how to write them. ## Attach image material Attach images only when the criterion really needs to see them. Keep the same `defineJudge` and `t.judge`; you do not need a second Judge or score gate. First set `supportsImages: true` in the existing chat Provider's configuration, and switch `model` to a model that supports both images and tool calls. `TypesafeProvider` does not support images. The eval below reads the `screenshot.png` the application has already saved, and does not take a new screenshot for the eval: ```ts theme={null} import { readFile } from "node:fs/promises"; import { defineEval, defineJudge } from "niceeval"; import { judgeImage } from "niceeval/judge"; const actionQuality = defineJudge({ name: "action-quality", rubric: "Rate whether the action is reasonable based on known facts; the image is only supplementary, and do not infer actions outside the frame.", }); export default defineEval({ async test(t) { const screenshot = judgeImage({ body: await readFile("screenshot.png"), mediaType: "image/png", }); t.judge({ action: "The character finished carrying the item", screenshot }, actionQuality).gate(0.8); }, }); ``` Images must be PNG or JPEG, at most 4 MiB each. When an image is too large or has an invalid file header, NiceEval rejects it before sending. Do not pass a path, URL, or plain base64 string in place of an image. After the run, use `query`'s `attempt.assertion.detail` to view this assertion; the result includes the image's `imageId` and digest. To get the original image back, use `attempt.assertion.image` with the same Attempt's `entryId` and `imageId`, reading in segments: `offset` is the byte offset, and `limit` is at most 256 KiB. It reads the saved image and never rereads the file or calls the model. ## Run once and confirm you get a score Make sure an existing Experiment's `evals` includes `judge-smoke`, then run: ```sh theme={null} pnpm exec niceeval exp local judge-smoke ``` NiceEval does not contact the Judge endpoint when loading configuration; it only sends a request when a Judge assertion really executes. So a configuration mistake only shows up after a run. After the run, copy the Run ID from the terminal's `NEXT` panel and open this run: ```sh theme={null} pnpm exec niceeval show --run ``` Copy the locator of the `judge-smoke` Attempt and view the assertion result: ```sh theme={null} pnpm exec niceeval show @ ``` When the configuration is correct, this assertion shows a score and whether it reached the threshold. You can also view it in the browser with `pnpm exec niceeval view --run `. When you need the reasons the Judge gave and the material actually sent to it, use `query`'s `attempt.assertion.detail`; see the example in [View Run Results](/docs/tutorials/viewing-results#see-why-an-assertion-got-its-score). When you get no score, decide what to check from the result: | Result you see | Cause | Check first | | - | - | - | | `unavailable`, reason `judge-provider-unresolved` | No Provider configured; no request was sent | `judgeRuntime` in `niceeval.config.ts` | | `unavailable`, reason `judge-key-unresolved` | The key could not be read; no request was sent | Whether the variable named in `apiKeyEnv` is set | | `unavailable` | The request could not be sent or timed out | Network, `baseUrl`, `timeoutMs` | | `errored` | The endpoint returned HTTP 400, the protocol is incompatible, or the response is not in the required format | Whether `baseUrl` points to a compatible gateway, and whether the `model` name is correct | | `errored` | The Judge gave no judgment, used the wrong format, or returned an out-of-range score | Whether the model supports tool calls | To compare different Judges, have several Experiments use different model strings or named Providers, and mark the dimension being compared with `labels`. # View Run Results: Find the Cause of Failure from the Overview Source: https://niceeval.com/docs/tutorials/viewing-results After evals run, use niceeval show in the terminal to drill down from the overview to the failed run and see its conversation, durations, usage, and file changes; browse with view in the browser; and have scripts and CI read JSON with query. A batch of evals finishes and the terminal shows `FAILED`: 3 of 12 did not pass. You want to know which ones, what the Agent actually said in those runs, which tools it called, and why it did not meet the assertions. NiceEval gives different readers three entry points, all reading the same results: | Entry point | For | Suits | | - | - | - | | `niceeval show` | You and Coding Agents, in the terminal | Finding failures quickly and drilling into details | | `niceeval view` | People, in the browser | Browsing full conversations, duration waterfalls, and comparing several Runs | | `niceeval query` | Scripts and CI, and Agents that need exact fields | Reading structured JSON | Results are readable as soon as each Attempt finishes; you do not have to wait for the whole batch. ## Start from the overview ```sh theme={null} pnpm exec niceeval show ``` The default overview answers "which results is the current project still missing": the denominator is your current Experiments and evals, not everything ever run. Attempts without problems are collapsed by default, so failures and the next command to run stay on one screen. The output looks like this: ```text theme={null} Current results Totals Covered 11/12 Gaps 1 Verdicts 8 passed; 3 failed; 0 errored; 0 skipped Pass rate 72.73% (partial) Experiments Experiment Covered Gaps Pass rate ---------- ------- ---- ----------------- my-agent 11/12 1 72.73% (partial) Attempts · my-agent Eval refund-policy Gap identity-mismatch Previous result @1K1P0VJAPVJ12 8 passed Attempts hidden See more niceeval show --experiment my-agent Next: niceeval exp my-agent --dry ``` A few numbers worth looking at first: * `Covered` is the number of positions that currently have a usable result, out of the planned number. When `Gaps` is not 0, some positions have no result yet, or the old result no longer applies, for example because you changed this eval. Under each gap is the reason and an entry to the last result; old scores are not counted in the current pass rate, and the pass rate is followed by `(partial)` to remind you it is based only on available results. * When `errored` is not 0, handle those first. `errored` means the run itself did not complete, for example the application was not running or the interface returned an error, so those Attempts reached no conclusion. * Scored evals show `Score` instead of a pass rate; see [Pass/Fail and Scored Evals](/docs/tutorials/evaluation-kinds) for how to read it. To fill the gaps, run the suggested `niceeval exp --dry` on the last line to see what would run, then drop `--dry` to run for real. When you see `Attempts hidden`, narrow the scope with the suggested command; to show every Attempt, add `--all`. ## Narrow to one Experiment or one run ```sh theme={null} pnpm exec niceeval show --experiment my-agent pnpm exec niceeval show --run ``` `--experiment` shows the result of each eval under one Experiment. `--run` shows one run; the Run ID is in the `NEXT` panel when `exp` finishes. Both can be passed several times. You can also look while a run is still going: finished Attempts appear, and unfinished ones show as `pending`. ## Look at one failed run In the overview and Experiment views, each Attempt is preceded by an Attempt locator starting with `@`, which uniquely points to that run within the project. Copy it: ```sh theme={null} pnpm exec niceeval show @1K1P0VJAPVJ12 ``` The main part of the output looks like this: ```text theme={null} Attempt @1K1P0VJAPVJ12 · failed Experiment my-agent Eval refund-policy Outcome completed Verdict failed assertions available · 3 entries Assertion Entry ID Group Called the weather tool e1 root Explains the weather e2 root Answer is grounded e3 root Sections Source available Execution available Timing available Usage available Diff not-recorded Next niceeval show @1K1P0VJAPVJ12 --source niceeval show @1K1P0VJAPVJ12 --execution niceeval show @1K1P0VJAPVJ12 --timing niceeval show @1K1P0VJAPVJ12 --usage niceeval show @1K1P0VJAPVJ12 --diff ``` `Next` lists the aspects you can keep looking at. Choose by the question you want to answer: | To find out | Command | | - | - | | What the Agent said and which tools it called | `--execution` | | Which assertion failed, and which source line it is on | `--source` | | Which step the time went to | `--timing` | | How many tokens were used and how much it cost | `--usage` | | Which files the Agent changed | `--diff` | `--execution` gives an outline of the conversation and tool calls. To expand one item, copy the ID shown in the outline: ```sh theme={null} pnpm exec niceeval show @1K1P0VJAPVJ12 --execution --expand ``` When an item shows `not-recorded`, `partial`, or `unavailable`, that information was not collected or is incomplete; it does not mean zero or failure. For example, a directly connected application has no file changes, so `diff` is `not-recorded`. ## Decide whether to change the application or the assertion `failed` only means at least one assertion was not met; it does not necessarily mean the Agent got it wrong. After reading `--execution`, there are three cases: * The Agent's output really does not meet the task requirements: change the application or the prompt. * The Agent's output meets the task requirements, but the assertion rejected it, for example a regex that is too rigid: change the eval. * The task itself is vague, so you cannot say whether the Agent got it right: first make the task and criteria clear. Do not loosen assertions arbitrarily just to turn results green. ## View in the browser ```sh theme={null} pnpm exec niceeval view pnpm exec niceeval view --run ``` Without arguments it opens the overview, and `--run` opens one or more specific runs. In the browser you see full conversations, the inputs and outputs of tool calls, and duration waterfalls. When new results are published, the page prompts you to refresh and only switches after you confirm, so what you are looking at does not change suddenly. `--no-open` does not open the browser automatically, and `--port` sets the port. ## See why an assertion got its score `show` tells you which assertion failed. To see the reasons the Judge gave and exactly how many points a scoring item got, read that assertion's detail with `query`. First get this run's list of assertions: ```sh theme={null} pnpm --silent exec niceeval query run --request - <<'JSON' { "protocol": "niceeval.query/v1", "operation": { "kind": "attempt.get", "locator": "@1K1P0VJAPVJ12" } } JSON ``` Pick an `entryId` from the returned `assertions.entries`, then read its detail: ```sh theme={null} pnpm --silent exec niceeval query run --request - <<'JSON' { "protocol": "niceeval.query/v1", "operation": { "kind": "attempt.assertion.detail", "locator": "@1K1P0VJAPVJ12", "entryId": "" } } JSON ``` In the detail, contribution is how many points this item earned, evaluation is the actual measured result, and the Judge's reasons are in explanation. When a field is missing or marked `unavailable`, interpret it by the returned reason; do not infer it from other fields. ## Let scripts and CI read results `query` outputs only JSON and does no terminal formatting. Do not parse the text output of `show`, and do not read files under `.niceeval/` directly; the format may change between versions. ```sh theme={null} pnpm --silent exec niceeval query discover ``` `discover` lists every request type the current version supports, such as listing Runs, reading a Run summary, reading Attempt details, traces, diffs, and comparing two Runs. After writing a request following the [CLI reference](/docs/reference/cli), first check it with `query explain --request `, then run it with `query run --request `. In pnpm projects, add `--silent`; otherwise pnpm's own output gets mixed into the JSON. To compare two runs, see [Compare Two Runs](/docs/tutorials/compare-runs). # Organize Experiments: Compare Models, Prompts, and Agents Source: https://niceeval.com/docs/tutorials/write-experiment Use defineExperiment to fix the Agent under test, model, flags, and number of runs. Write two Experiments that differ in only one variable over the same evals to compare two models or two prompt versions. You wrote a new system prompt and want to know whether it is better or worse than the old one. The evals already exist; now you need to run them against both versions and look at the results side by side. That is what Experiments are for. An eval only says "what to send and what counts as correct"; an Experiment says "what to run it against": which Agent, which model, which flags, how many times, and how much to spend before stopping. Keeping them separate means the same evals can compare any two configurations. ## A minimal experiment ```ts theme={null} // experiments/local.ts import { defineExperiment } from "niceeval"; import { webAgent } from "../agents/web-agent.ts"; export default defineExperiment({ agent: webAgent({ baseUrl: "http://127.0.0.1:5188" }), }); ``` The file path is the Experiment ID, here `local`. `agent` holds an already configured Adapter instance; connection parameters such as the address and auth of the application under test are passed to the Adapter factory and are not written anywhere else. ```bash theme={null} pnpm exec niceeval exp local ``` ## Compare two models One Experiment file is one configuration. To compare two models, write two files and change only the `model` line: ```text theme={null} experiments/ models/gpt-5.4.ts models/deepseek-v4-pro.ts ``` ```ts theme={null} // experiments/models/gpt-5.4.ts import { defineExperiment } from "niceeval"; import { webAgent } from "../../agents/web-agent.ts"; export default defineExperiment({ description: "gpt-5.4", agent: webAgent({ baseUrl: "http://127.0.0.1:5188" }), model: "gpt-5.4", attempts: 3, }); ``` `model` is passed to the Adapter as `ctx.model`. The Adapter must put it into the request it sends to the application, or the application will not really switch models; see [Connect Your Agent](/docs/tutorials/connect-your-agent). `attempts: 3` runs each eval 3 times. The Agent answers differently every time, so a pass rate from a single run does not say much. ## Compare two prompt versions The prompt version lives inside the application, and NiceEval cannot see it. The approach is the same as A/B testing in a product: the application accepts a flag and switches the prompt based on it; the Experiment assigns the flag's value. ```ts theme={null} // experiments/prompts/v1.ts import { defineExperiment } from "niceeval"; import { webAgent } from "../../agents/web-agent.ts"; export default defineExperiment({ description: "System Prompt v1 (baseline)", agent: webAgent({ baseUrl: "https://staging.example.com" }), model: "gpt-5.4", flags: { promptVariant: "v1" }, // The only difference between the two files attempts: 3, budget: 5, }); ``` `experiments/prompts/v2.ts` changes `promptVariant` to `"v2"` and is otherwise identical word for word. The less the two configurations differ, the more the score difference can be attributed to the prompt. The Adapter forwards `ctx.flags` to the application unchanged: ```ts theme={null} // agents/web-agent.ts async send(input, ctx) { const r = await fetch(`${baseUrl}/api/turn`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ message: input.text, model: ctx.model, flags: ctx.flags }), signal: ctx.signal, }); // ...translate the reply into events } ``` `flags` is a flat object whose values can only be strings, finite numbers, or booleans. Nested objects, arrays, `null`, and explicit `undefined` are all rejected before the run. Evals can read the same values with `t.flags`. When you need to check that flags are valid or fill in defaults, declare a synchronous `parseFlags(input: unknown)` on the Adapter. Its return value decides the type of `flags` in the experiment, and the normalized value takes part in the result reuse decision. The most common mistake is writing the same `flags` or the same `model` in both files, for example both reading the default of the same environment variable. Runs and reports look normal, but the two columns compare the same configuration. ## Run a group, or just one cell Positional arguments select Experiments by ID prefix. Directories only organize IDs and batch selection: ```bash theme={null} pnpm exec niceeval exp prompts # All experiments under prompts/ pnpm exec niceeval exp prompts/v2 # Only the v2 cell ``` When one cell's result looks odd and needs reproducing on its own, write its full ID; you do not need to run the whole group first or move other files away. Follow it with an eval ID prefix to run only a few evals: ```bash theme={null} pnpm exec niceeval exp prompts/v2 refund-policy ``` To confirm before running which evals run and how many times, add `--dry`; see [Select and Preview the Run Scope](/docs/tutorials/select-and-preview). ## See the comparison After both configurations run, view each in the terminal: ```bash theme={null} pnpm exec niceeval show --experiment prompts/v1 --experiment prompts/v2 ``` Each Experiment's `Summary` lists these items: ```text theme={null} Observed 12/12 Verdicts 8 passed; 4 failed; 0 errored; 0 skipped Pass rate 66.67% ``` `Observed` is the number of Attempts with results out of the planned number. When the two numbers differ, some Attempts have no result yet, and the pass rate cannot be compared directly with the other side. Besides the pass rate, look at duration, tokens, cost, and the kinds of failures. A configuration with a slightly higher pass rate at double the cost is not necessarily worth switching to. To compare two Runs side by side, see [Compare Pinned Runs](/docs/tutorials/compare-runs); to view in the browser, use `niceeval view`. ## Make the comparison meaningful * Keep the set of evals unchanged during the comparison; otherwise the two sides are not comparing the same evals. * For highly non-deterministic Agents, especially Coding Agents, run each configuration at least 3 times. * Change one variable at a time. If you switch the model and the prompt together, you cannot tell which one deserves the credit. * Group failures by cause; do not just look at the total score. ## Run only some evals Each Experiment chooses which evals to run with `evals`, defaulting to all. You can write an array of ID prefixes, or a function: ```ts theme={null} evals: (eval) => eval.id.startsWith("coding/") && eval.tags.includes("coding"), ``` `eval.id` is the in-project ID derived from the file path, not an absolute path, so you can test it directly with `startsWith`. ## Common fields | Field | Purpose | | - | - | | `agent` | Choose and configure the Adapter. Required | | `model` | A single model name, passed to the Adapter through `ctx.model` | | `reasoningEffort` | Reasoning effort, for example `"high"`, passed through `ctx.reasoningEffort` / `t.reasoningEffort` | | `flags` | Experiment conditions, passed through `ctx.flags` / `t.flags` | | `attempts` | The maximum number of runs per eval | | `earlyExit` | Stop the remaining Attempts after one pass. Off by default; running all of them gives the full pass rate | | `evals` | `"*"`, an array of ID prefixes, or a function returning a Boolean | | `timeoutMs` | Timeout per Attempt | | `budget` | This configuration's spending cap, in US dollars | | `maxConcurrency` | How many Attempts this configuration runs at once | | `labels` | Annotations used only for report grouping; neither the Adapter nor evals can read them | | `sandbox` | The Sandbox used by Coding Agents; see [Sandbox](/docs/tutorials/sandbox-providers) | | `plugins` | Reuse a complete set of run conditions; see [Plugin](/docs/tutorials/plugins) | | `setup` / `teardown` | Start and stop services shared by the whole experiment; see [Start Shared Experiment Services](/docs/tutorials/experiment-services) | | `sharedState` | An exclusive key for sharing state across runs; see [Start Shared Experiment Services](/docs/tutorials/experiment-services#save-state-across-attempts) | For the full set of fields, see the [defineExperiment reference](/docs/reference/define-experiment). ## Different evals need different environments A batch of real tasks may need different runtime versions, for example some tasks need Python 3.9. In that case, write the Sandbox directly on the eval, so the task and the environment it needs live together: ```ts theme={null} // evals/astropy-2021.eval.ts export default defineEval({ sandbox: e2bSandbox({ template: "codex-python39" }), async test(t) { // Send the task and verify the result }, }); ``` When several evals share the same environment, reuse it with an ordinary function: ```ts theme={null} // evals/shared/python39.ts import { e2bSandbox } from "niceeval/sandbox"; export const python39 = () => e2bSandbox({ template: "codex-python39" }); ``` The Experiment then only needs to specify the Agent, and the same Experiment can still cover every eval. Before creating any Sandbox, NiceEval checks that each eval's environment is compatible with the Experiment's configuration. ## One application with several models When the application has different internal roles such as planning, replying, and reviewing, each using its own model, assign them by purpose with `models`: ```ts theme={null} export default defineExperiment({ adapter: workflowAdapter, models: { planner: { model: "provider/model-a" }, responder: { model: "provider/model-b" }, reviewer: { model: "provider/model-a" }, }, }); ``` The Adapter reads this configuration from `ctx.models` and hands it to the application, and on each model request it writes the matching `modelSlot` in `ctx.recordUsage`. That way usage and cost can be counted separately by purpose; roles that are never called incur no cost. `models` cannot be written together with `model` or `reasoningEffort`. Agent-style Adapters use only a single `model`. After the run, use `niceeval show @ --usage` to see each model's usage and cost in one run. When Judge usage was not reported, it shows as unavailable, and the total cost gives only the known part. # Write Send Source: https://niceeval.com/docs/tutorials/write-send Write an Adapter's send function in seven steps: send messages, continue sessions, record usage, map tool events, handle human-in-the-loop (HITL), connect OTel traces, and pass through Experiment flags. [Adapter](/docs/explanation/adapter) defines the `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](/docs/tutorials/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. `send` does 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/adapter` supplies converters from raw responses to the standard event stream. `ctx.session` supplies the state APIs needed to continue sessions and resume a paused HITL interaction. * **Send runtime feedback through `ctx`, not directly to the terminal.** Use `ctx.progress(...)` for long steps. Use `ctx.diagnostic(...)` for degradation or error context you need to inspect after a run. Throw when execution cannot continue. Do not call `console.log/error` or write `process.stdout/stderr` from an Adapter. ## Confirm the application interface shape Before writing `send`, identify your application's response shape. [NiceEval](https://niceeval.com/) 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: | Protocol | Defined by | Description | Reference | | - | - | - | - | | **Chat Completions** | OpenAI. An industry de facto standard; almost every model provider offers a compatible endpoint. | Stateless request/response: the client sends the complete `messages` list every turn and receives one JSON response. | [API reference](https://platform.openai.com/docs/api-reference/chat) | | **Responses / Open Responses** | OpenAI (Responses API). Open Responses is an open specification based on it, initiated by OpenAI and built with communities such as Hugging Face for multi-provider interoperability. | Stateful and Agent-oriented: a request carries `previous_response_id` so the server continues history, and its `output` array promises a complete process record. | [API reference](https://platform.openai.com/docs/api-reference/responses) · [Open Responses specification](https://www.openresponses.org/specification) | | **AI SDK (Vercel)** | Vercel. A de facto standard in the TypeScript ecosystem. | It is an SDK rather than an online protocol: `generateText` returns a complete result, while `useChat` uses its defined UI Message Stream protocol. | [generateText reference](https://ai-sdk.dev/docs/reference/ai-sdk-core/generate-text) · [UI Message Stream protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol) | 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 smallest `send` does three things: send `input.text` to the application API, put the reply in a `message` event, and report the Turn's `status`: ```ts theme={null} // agents/chat-app.ts import { completeEvidenceCoverage, defineAgent } from "niceeval/adapter"; const BASE_URL = "http://localhost:8080"; // To switch addresses by Experiment, make this a factory argument; see the connection guide. export default defineAgent({ name: "chat-app", evidenceCoverage: completeEvidenceCoverage, async send(input, ctx) { ctx.progress({ message: "Waiting for the Chat API" }); const res = await fetch(`${BASE_URL}/v1/chat/completions`, { // ← The interface the frontend already uses method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages: [{ role: "user", content: input.text }], model: ctx.model, // ← experiment.model arrives here through ctx.model. Delete this line if the application interface cannot select a model. }), signal: ctx.signal, // ← Runner timeout and cancellation }).then((r) => r.json()); return { status: "completed", events: [{ type: "message", role: "assistant", text: res.choices[0].message.content }], }; }, }); ``` When an interface returns a response you can continue processing but whose evidence is incomplete, report a diagnostic instead of printing the full raw response: ```ts theme={null} if (!res.requestId) { ctx.diagnostic({ code: "missing-request-id", level: "warning", message: "The response has no request id and cannot be correlated with server logs", }); } ``` `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 @` 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](/docs/explanation/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 of `send` 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()`, then read and write it with `ctx.session.get(slot)` / `set(slot, messages)`. * **The server keeps history**—the interface accepts a session ID, such as a Responses-shaped `previous_response_id` or an SDK-native session/thread—→ use `ctx.session.id` and `ctx.session.capture(id)`. The main example uses the first form: ```ts theme={null} // agents/chat-app.ts import { completeEvidenceCoverage, createSessionSlot, defineAgent } from "niceeval/adapter"; const BASE_URL = "http://localhost:8080"; interface Msg { role: "user" | "assistant"; content: string | null } const historySlot = createSessionSlot("chat-app/history"); export default defineAgent({ name: "chat-app", evidenceCoverage: completeEvidenceCoverage, async send(input, ctx) { const history = ctx.session.get(historySlot) ?? []; // ← This slot has not been written on a new session line. const messages = [...history, { role: "user" as const, content: input.text }]; const res = await fetch(`${BASE_URL}/v1/chat/completions`, { // … method, headers, and signal are the same as step one; omitted body: JSON.stringify({ messages, model: ctx.model }), // ← The change: send complete history. Forward model as in step one. }).then((r) => r.json()); const reply = res.choices[0].message; ctx.session.set(historySlot, [...messages, reply]); // ← Write it back. The next Turn on this line gets it automatically. return { status: "completed", events: [{ type: "message", role: "assistant", text: reply.content }], }; }, }); ``` No first-turn branch is needed. On a new session line, `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`: ```ts theme={null} // Inside send: // Send the prior Turn's id in the request body … previous_response_id: ctx.session.id ← It is naturally undefined on a new session line. // Capture a returned id … ctx.session.capture(res.id) ← The next Turn continues from it. ``` `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: ```ts theme={null} // … transport and session are the same as step two; omitted return { status: "completed", events: [{ type: "message", role: "assistant", text: reply.content }], usage: { // ← The change: report the usage returned by the interface truthfully. inputTokens: res.usage.prompt_tokens, outputTokens: res.usage.completion_tokens, }, }; ``` `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](/docs/reference/define-config) for prices and [Control run cost](/docs/tutorials/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](/docs/explanation/adapter). ```ts theme={null} import type { CommandProjection } from "niceeval/adapter"; type StreamEvent = | { type: "message"; role: "assistant" | "user"; text: string } | { type: "operation.started"; operationId: string; operation: | { kind: "tool"; name: string; input: JsonValue; tool?: ToolName; command?: CommandProjection } | { kind: "subagent"; name: string; remoteUrl?: string } } | { type: "operation.finished"; operationId: string; kind: "tool"; output?: JsonValue; status: "completed" | "failed" | "rejected" } | { type: "operation.finished"; operationId: string; kind: "subagent"; output?: JsonValue; status: "completed" | "failed" } | { type: "skill.loaded"; skill: string; operationId?: string } | { type: "input.requested"; request: InputRequest } | { type: "thinking"; text: string } | { type: "compaction"; reason?: string } | { type: "error"; message: string }; ``` Parsing is a “response field → event” map. Hand-written, it looks like this; the rest of `send` is exactly the same as step two: ```ts theme={null} // … transport and session are the same as step two; usage is the same as step three; omitted const reply = res.choices[0].message; const events: StreamEvent[] = []; for (const call of reply.tool_calls ?? []) { events.push({ type: "operation.started", operationId: call.id, operation: { kind: "tool", name: call.function.name, input: JSON.parse(call.function.arguments), }, }); } if (reply.content) events.push({ type: "message", role: "assistant", text: reply.content }); return { events, status: "completed", usage }; ``` Usually you **do not need to write this loop**. When a response has a standard shape, one official converter line replaces all of it. The return value includes `events`, `status`, and even the manually copied `usage` from step three, so return it directly: ```ts theme={null} // … transport and session are the same as step two; omitted return turnFromChatCompletion(res); // ← The mapping above plus step three's usage, in the official version. ``` For a different interface shape, use the matching piece: | Interface response shape | Built-in piece | Code you need to write | | - | - | - | | Chat Completions-shaped response | `turnFromChatCompletion(res)` | No mapping to hand-write | | Responses-shaped response | `turnFromResponses(res)` | No mapping to hand-write | | Complete result from AI SDK `generateText` | `turnFromAiSdk(result)` | No mapping to hand-write | | Streaming: SDK-native events passed through, with one complete unit per frame | `createClaudeSdkEventStream()` / `createPiAgentEventStream()` / `createCodexThreadEventStream()` | No mapping to hand-write | | Streaming: the AI SDK UI Message Stream, the `useChat` protocol | `uiMessageStreamAgent()` | No `send` or event mapping to hand-write | | Streaming: token-by-token or argument-by-argument deltas | The protocol's official reducer; use `deltaStream({ toOps })` only when it has none | One “frame type → operation” mapping table | | Final answer only | A few hand-written events | Start with one `message`; step one is this tier | 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: | Events you emit | Assertions unlocked | | - | - | | `message` | `t.reply`, `t.check(turn.message, includes(...))` / `pattern(...)`, Judge conversation material | | Tool `operation.started` / `operation.finished`, paired by `operationId` | `turn.calledTool()` / `turn.toolOrder()` / `turn.maxToolCalls()` / `turn.noFailedActions()`… | | `input.requested`, with status `"waiting"` | `t.check(turn.status, equals("waiting"))`, `t.requireInputRequest()`, `t.respond()` (step five) | | `thinking` / `compaction` / `error` | Reports and diagnostics; you can also pass raw `events` to an ordinary `check(events, match)` | 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 @ --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 of `send` have obligations: * **The pausing Turn** returns `status: "waiting"` and emits an `input.requested` event with a stable `id` for every pending question. `t.check(turn.status, equals("waiting"))` and `t.requireInputRequest()` read them, and answers match by this `id`. * **The answer Turn** reaches the Adapter from an Eval's `t.respond(...)` as **another ordinary `send`** on the same session line with the same state. A human decision arrives structurally through `input.responses`; each entry carries `requestId`, `optionId`, or `text`. See [Inputs for the Different Answers](/docs/explanation/adapter#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 tool `operation.finished` event's `status` to `"rejected"`, not `"failed"`: rejection is a human decision, not a tool failure, so `noFailedActions()` does not misfire. Store unfinished work, such as a partly read SSE stream, in `ctx.session` too. Declare private state with `createSessionSlot()`, 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](https://github.com/CorrectRoadH/niceeval/tree/main/examples/zh/tier1): ```ts theme={null} // agents/my-app.ts import { completeEvidenceCoverage, createSessionSlot, defineAgent, sseJsonFrames, createPiAgentEventStream, driveFrameStream } from "niceeval/adapter"; import type { AgentContext, PiAgentStream, SseFrameCursor } from "niceeval/adapter"; import type { Turn, TurnInput } from "niceeval"; const BASE_URL = "http://localhost:5299"; // The application's own port; evals do not manage its process. interface Pending { cursor: SseFrameCursor; stream: PiAgentStream; callId: string } const pendingSlot = createSessionSlot("my-app/pending"); function readStream(cursor: SseFrameCursor, ctx: AgentContext, stream: PiAgentStream): Promise { return driveFrameStream(cursor, stream, ctx, (frame) => { if (frame.type === "session") { ctx.session.capture(frame.sessionId); // ← Step two: write the returned id back so the next Turn continues. return; } if (frame.type === "approval_request") { // ← This step: the application pauses for approval, so retain the scene. ctx.session.set(pendingSlot, { cursor, stream, callId: frame.toolCallId }); return { pause: { id: frame.toolCallId, action: frame.toolName, options: [{ id: "approve" }, { id: "deny" }] } }; // On pause, driveFrameStream adds input.requested, sets status to "waiting", and stops reading the stream. } if (frame.type === "server_error") return { fail: frame.message }; }); } export default defineAgent({ name: "my-app", evidenceCoverage: completeEvidenceCoverage, async send(input: TurnInput, ctx: AgentContext): Promise { const held = ctx.session.take(pendingSlot); if (held) { // ← t.respond("approve"): an ordinary send; deliver the decision first. const approved = input.responses?.[0]?.optionId === "approve"; // Match by requestId when several requests are pending. if (!approved) held.stream.markRejected(held.callId); await fetch(`${BASE_URL}/api/chat/approve`, { method: "POST", signal: ctx.signal, body: JSON.stringify({ toolUseId: held.callId, approved }) }); return readStream(held.cursor, ctx, held.stream); // Then keep reading the same stream; do not send a new request. } const res = await fetch(`${BASE_URL}/api/chat`, { // ← Transport: the only part you truly hand-write. method: "POST", body: JSON.stringify({ message: input.text, // ← Step one: send the message. model: ctx.model, // ← Step one: forward experiment.model. sessionId: ctx.session.id, // ← Step two: naturally absent on the first send and present afterward. }), signal: ctx.signal, }); return readStream(sseJsonFrames(res.body!), ctx, createPiAgentEventStream()); // ← Step four: official converter with no mapping. }, }); ``` For an interface without HITL, remove the three waiting-related parts: `Pending`, `pendingSlot`, and the initial `take` branch. Keep the rest. See [HITL](/docs/explanation/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 through `send` on every turn. **Configure the receiver address at application startup, outside `send`.** [NiceEval](https://niceeval.com/) 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: ```ts theme={null} // niceeval.config.ts import { defineConfig } from "niceeval"; export default defineConfig({ telemetry: { port: 4318 }, // The receiver always listens on http://localhost:4318/v1/traces. }); ``` ```bash theme={null} OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces node server.js ``` **What `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: ```ts theme={null} const res = await fetch(`${BASE_URL}/v1/chat/completions`, { // … the rest is the same as step four; omitted headers: { "content-type": "application/json", ...ctx.telemetry?.headers }, // ← The only added line. }).then((r) => r.json()); ``` `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](/docs/tutorials/connect-otel). ## Step 7: pass through Experiment flags for A/B comparison To compare prompt versions or strategies, expose them as switchable application configuration. The Experiment declares `flags`; 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: ```ts theme={null} // … the rest of send is the same as before; omitted body: JSON.stringify({ messages, model: ctx.model, // ← experiment.model has been forwarded since step one; it appears here with flags. flags: ctx.flags, // ← experiment.flags passes through unchanged. It is {} when not configured. }), ``` ```ts theme={null} // experiments/concise.ts import { defineExperiment } from "niceeval"; import chatApp from "../agents/chat-app.ts"; export default defineExperiment({ agent: chatApp, model: "gpt-5.4", // ← Reaches send through ctx.model. flags: { promptVariant: "concise" }, // ← Reaches send through ctx.flags every Turn. }); ``` Declare `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](/docs/explanation/tier) for effort and benefits, and [Write experiments](/docs/tutorials/write-experiment) 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 --experiment ` 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 same `send` function with different fields; there is no second method to implement: | Eval-side API | Adapter call | What `send` receives | | - | - | - | | `t.send(text)` | One `send` | `input.text`; `ctx.session` is this session line's own state. | | `t.sendFile(path, text?)` | One `send` | The same, plus `input.files` (base64 `InputFile[]`). Put them in the request as the application interface expects, or ignore them when it is not multimodal. | | `t.newSession()` | Does not trigger `send` itself | Opens a second session line. That line's next first `send` receives **completely empty** state, exactly like the Eval's first `send`. | | `t.respond(...answers)` | One **ordinary** `send` | `input.text` is the answer text. `input.responses` has one `{ requestId, optionId }` per request, or `{ requestId, text }` for free-text answers. It is still the same line and state. | | `t.respondAll(optionId)` | One **ordinary** `send` | The same, with one answer for every pending request. `optionId` is validated against every request on the Eval side; a typo throws instead of silently reaching the application. | ## Related reading * [Adapter](/docs/explanation/adapter) — `send` inputs, outputs, three integration tiers, and capability sources. * [Connect Your Agent](/docs/tutorials/connect-your-agent) — minimal integration, parameter passing, and optional capabilities. * [HITL](/docs/explanation/hitl) — the complete concept of pausing to wait for a person: handshake timing and both sides' obligations. * [Drive](/docs/explanation/drive) — how an Eval uses `t.send()`, `t.newSession()`, and HITL. * [Assert](/docs/explanation/assert) — the complete assertion vocabulary driven by the standard event stream.