Local Experiment Runs
Write experiment files that run locally via the SDK, then push results to the platform for comparison and visualization.
Local Experiment Runs
Local experiment runs let you write a task function in code, run it against a dataset on your machine, and push scores and traces to the platform — without configuring a prompt or evaluator list in the UI first. Results appear on the same experiments page as server-side runs. Each run also gets a baseline comparison (a verdict plus per-evaluator deltas against the most recent completed run) returned in the SDK result and printed in the CLI output. Note: the dashboard's Compare and Set as baseline actions are disabled for local experiments — the comparison is surfaced in your terminal/CI, not the UI.
How it works
The flow is two steps:
Experiments.create()— registers the experiment name on the platform and returns a handle with anexperimentRunId.ExperimentRuns.create(handle, { task })— resolves the dataset and evaluators, executestask(input)for each item, runs evaluators against the output, uploads traces and scores, then finalizes the run.
The standalone Experiments and ExperimentRuns objects read credentials from AISDK_PUBLIC_KEY and AISDK_SECRET_KEY environment variables — no AISDK client initialization is required.
Quick start
Name your file with the .experiment.ts extension to make it discoverable by the CLI.
import { Experiments, ExperimentRuns } from "@browserstack/ai-sdk";
import OpenAI from "openai";
const openai = new OpenAI(); // reads OPENAI_API_KEY from env
const experiment = await Experiments.create({
name: "qa-golden-set",
dataset: () => [
{ input: "What is the capital of France?", expected: "Paris" },
{ input: "What is 2 + 2?", expected: "4" },
],
evaluators: [
({ output, expected }) => ({
name: "exact_match",
score: String(output).trim() === String(expected).trim() ? 1 : 0,
}),
],
});
const result = await ExperimentRuns.create(experiment, {
task: async (input) => {
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: String(input) }],
});
return response.choices[0].message.content ?? "";
},
});
console.log("Run URL:", result.uiUrl);
console.log("Comparison:", result.comparison?.summary.verdict);Name your file with the .experiment.py extension.
import os
from browserstack_ai_sdk import Experiments, ExperimentRuns
import openai
_openai = openai.OpenAI() # reads OPENAI_API_KEY from env
def exact_match(args):
output = str(args.get("output", "")).strip()
expected = str(args.get("expected", "")).strip()
return {"name": "exact_match", "score": 1 if output == expected else 0}
def my_task(input_val):
response = _openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": str(input_val)}],
)
return response.choices[0].message.content or ""
experiment = Experiments.create({
"name": "qa-golden-set",
"dataset": lambda: [
{"input": "What is the capital of France?", "expected": "Paris"},
{"input": "What is 2 + 2?", "expected": "4"},
],
"evaluators": [exact_match],
})
result = ExperimentRuns.create(experiment, {"task": my_task})
print("Run URL:", result["uiUrl"])Experiments.create() — Step 1
Creates (or upserts) an experiment by name and returns a handle for Step 2.
Local vs server routing. Experiments.create() creates a server-side experiment (the platform runs it) when you pass promptId + datasetId or datasetRunTagId and no task — see Experiments (server-side). For a local run (this page), pass a task in Step 2 with an inline/local dataset and evaluators, and omit promptId/datasetRunTagId.
An experiment name that already has server-side runs cannot accept local runs — the run fails with 409 Conflict. Use a fresh name (or a local-only experiment) for local runs.
import { Experiments, LocalExperimentCreateOptions } from "@browserstack/ai-sdk";
const experiment = await Experiments.create({
name: "qa-golden-set", // required — upserted by (project, name)
dataset: () => [...], // see Dataset sources below
evaluators: [myEvaluator], // see Evaluator sources below
evaluatorListId: "el_abc123", // optional — merge with inline evaluators
description: "weekly eval", // optional
metadata: { owner: "ml-team" }, // optional — stored on the run
});
// Returns LocalExperimentHandlefrom browserstack_ai_sdk import Experiments
experiment = Experiments.create({
"name": "qa-golden-set",
"dataset": lambda: [...],
"evaluators": [my_evaluator],
"evaluatorListId": "el_abc123", # optional
"description": "weekly eval", # optional
"metadata": {"owner": "ml-team"}, # optional
})
# Returns a dict handle used in Step 2Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Experiment name — upserted by (projectId, name). Same name accumulates runs. |
dataset | function | {name, version?} | {file} | No* | Dataset definition — see Dataset sources |
datasetId | string | No* | Platform dataset ID. Mutually exclusive with dataset. |
evaluators | EvaluatorInput[] | No | Inline evaluator functions or {name, version?} references to platform evaluators |
evaluatorListId | string | No | Platform evaluator list ID — merged with inline evaluators |
description | string | No | Human-readable description stored on the experiment |
metadata | Record<string, unknown> | No | Arbitrary key-value metadata stored on the run |
* Either dataset or datasetId must be provided here in Step 1. The dataset is fixed on the handle — it cannot be passed to ExperimentRuns.create() in Step 2.
ExperimentRuns.create() — Step 2
Executes the experiment and returns a result with scores and a comparison against the baseline.
Pass the handle object returned by Experiments.create(), not an experiment ID string. Passing a string ID triggers the server-side run path instead.
import { ExperimentRuns } from "@browserstack/ai-sdk";
const result = await ExperimentRuns.create(experiment, {
task: async (input) => {
// input is one dataset item's `input` field
// return the model output (string, object, or any serializable value)
return await callMyLLM(input);
},
maxConcurrency: 5, // optional — default: 1
trialCount: 1, // optional — repeat each item N times
timeout: 30_000, // optional — overall run timeout in ms (whole-run deadline)
hooks: {
onTestCase: (item, result) => console.log(item.input, result.scores),
onScore: (item, scores) => console.log(item.input, scores),
onComplete: (summary) => console.log(summary.successCount, "passed"),
},
});from browserstack_ai_sdk import ExperimentRuns
result = ExperimentRuns.create(experiment, {
"task": my_task,
"maxConcurrency": 5, # optional — default: 1
"trialCount": 1, # optional
"timeout": 30000, # optional — overall run timeout in ms
"hooks": { # optional — onTestCase / onScore / onComplete
"onComplete": lambda summary: print(summary["successCount"], "passed"),
},
})Task function
The task function receives a single input argument (the input field from each dataset item) and must return the model output. It does not receive a params argument; read AIEVALS_PARAMS_JSON from the environment to access CLI-injected parameters:
task: async (input) => {
const params = JSON.parse(process.env.AIEVALS_PARAMS_JSON || "{}");
const response = await openai.chat.completions.create({
model: params.model ?? "gpt-4o-mini",
messages: [{ role: "user", content: String(input) }],
});
return response.choices[0].message.content ?? "";
},import os, json
def my_task(input_val):
params = json.loads(os.environ.get("AIEVALS_PARAMS_JSON", "{}"))
return _openai.chat.completions.create(
model=params.get("model", "gpt-4o-mini"),
messages=[{"role": "user", "content": str(input_val)}],
).choices[0].message.contentReturn value (ExperimentRunResult)
| Field | Type | Description |
|---|---|---|
experimentRunId | string | Run ID |
experimentId | string | Experiment ID |
runName | string | Auto-generated run name |
uiUrl | string | Direct link to the run in the dashboard |
results | ItemResult[] | Per-item input, output, scores, duration |
comparison | ComparisonResult | Baseline comparison. Present on every completed run — inspect comparison.status (below) to tell whether a baseline actually existed. |
comparison always carries a status — use it, not if (result.comparison) (which is always truthy), to detect whether a baseline was compared against:
comparison.status | Meaning |
|---|---|
"ready" | Compared against a baseline. baseline.runId and per-evaluator baselineScore/delta are populated. |
"no_baseline" | No prior completed run to compare against (e.g. the first run). baseline is null; per-evaluator baselineScore/delta are null. summary.verdict is "PASS". |
"pending" | Scores are still aggregating server-side — retry. |
Full shape (matches the exported ComparisonResult type):
interface ComparisonResult {
status: "ready" | "no_baseline" | "pending";
baseline?: { runId: string };
evaluators: Array<{
name: string;
currentScore: number | null;
baselineScore: number | null;
delta: number | null;
deltaDirection: string | null;
}>;
summary: {
verdict: string; // "PASS" | "REGRESSION"
failureCount: number;
improvedCount: number;
regressedCount: number;
};
}comparison.summary.verdict is "PASS" or "REGRESSION" (and is "PASS" on a "no_baseline" run). Use it to gate CI:
if (result.comparison?.summary.verdict === "REGRESSION") {
console.error("Regression detected. See:", result.uiUrl);
process.exit(1);
}comparison = result.get("comparison") or {}
if comparison.get("summary", {}).get("verdict") == "REGRESSION":
import sys
print("Regression detected. See:", result["uiUrl"])
sys.exit(1)Dataset sources
Three ways to provide the dataset. dataset and datasetId are mutually exclusive.
| Source | TypeScript | Python |
|---|---|---|
| Inline function | dataset: () => [{ input, expected }] | "dataset": lambda: [{"input": ..., "expected": ...}] |
| Platform by name | dataset: { name: "my-dataset" } | "dataset": {"name": "my-dataset"} |
| Platform by name + version | dataset: { name: "my-dataset", version: 2 } | "dataset": {"name": "my-dataset", "version": 2} |
| Platform by ID | datasetId: "ds_abc123" | "datasetId": "ds_abc123" |
| CSV file | dataset: { file: "data.csv" } | "dataset": {"file": "data.csv"} |
The CSV must have an input column. expected and expected_output are recognized as expected values.
The inline dataset must be a function returning an array, not a bare array — TypeScript: dataset: () => [...] (not dataset: [...]); Python: "dataset": lambda: [...].
Evaluator sources
Evaluators can be inline functions, references to platform evaluators, or an evaluator list ID. All sources are merged and run locally.
Inline evaluator
Receives { input, output, expected?, context?, params } and must return { name: string, score: number }:
const exactMatch = ({ output, expected }) => ({
name: "exact_match",
score: String(output).trim() === String(expected).trim() ? 1 : 0,
});def exact_match(args):
output = str(args.get("output", "")).strip()
expected = str(args.get("expected", "")).strip()
return {"name": "exact_match", "score": 1 if output == expected else 0}Platform evaluator by name
evaluators: [{ name: "faithfulness", version: 1 }]Evaluator list
evaluatorListId: "el_abc123" // fetched from platform and merged with inline evaluatorsInline evaluators and evaluatorListId can coexist — all are resolved and run.
Parameter injection
Pass parameters at run time without changing the experiment file. Parameters are injected as the AIEVALS_PARAMS_JSON environment variable. Evaluators receive them automatically via the params argument; the task function must read the env var explicitly.
# Pass a single parameter
aievals experiment-run run . --param model=gpt-4o
# Sweep across values (creates one run per combination)
aievals experiment-run run . --matrix-param model=gpt-4o,gpt-4o-miniAll values injected via --param are strings — cast them in code as needed.
Platform effects
When a local run completes, the platform records:
| What | Where |
|---|---|
Traces tagged local_experiment, environment: experiment | Trace list |
| Scores grouped under the experiment name | Scores tab in trace peek panel |
Run with source: LOCAL | Run Source filter in experiments listing |
Automatic baseline comparison against the most recent COMPLETED run in the same experiment | SDK result + CLI output (comparison.summary.verdict). The dashboard Compare / Set as baseline actions are disabled for local experiments — comparison is not shown in the UI. |
Git metadata (commit, branch, author) from .git/ or CI env vars | Stored as ciMetadata on the run |
The Run Evaluator action is hidden for traces with environment: experiment, since scores were already computed locally.
Java SDK
Java SDK support for local experiment runs is coming. Use the Node or Python SDK for this workflow.
Related
- CLI: Running experiments locally —
aievals experiment-run runflags, file discovery, and matrix sweeps - Experiments (server-side) — prompt + dataset + evaluator list server runs