BrowserStack AI Evals
Evaluation

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:

  1. Experiments.create() — registers the experiment name on the platform and returns a handle with an experimentRunId.
  2. ExperimentRuns.create(handle, { task }) — resolves the dataset and evaluators, executes task(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 LocalExperimentHandle
from 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 2

Parameters

ParameterTypeRequiredDescription
namestringYesExperiment name — upserted by (projectId, name). Same name accumulates runs.
datasetfunction | {name, version?} | {file}No*Dataset definition — see Dataset sources
datasetIdstringNo*Platform dataset ID. Mutually exclusive with dataset.
evaluatorsEvaluatorInput[]NoInline evaluator functions or {name, version?} references to platform evaluators
evaluatorListIdstringNoPlatform evaluator list ID — merged with inline evaluators
descriptionstringNoHuman-readable description stored on the experiment
metadataRecord<string, unknown>NoArbitrary 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.content

Return value (ExperimentRunResult)

FieldTypeDescription
experimentRunIdstringRun ID
experimentIdstringExperiment ID
runNamestringAuto-generated run name
uiUrlstringDirect link to the run in the dashboard
resultsItemResult[]Per-item input, output, scores, duration
comparisonComparisonResultBaseline 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.statusMeaning
"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.

SourceTypeScriptPython
Inline functiondataset: () => [{ input, expected }]"dataset": lambda: [{"input": ..., "expected": ...}]
Platform by namedataset: { name: "my-dataset" }"dataset": {"name": "my-dataset"}
Platform by name + versiondataset: { name: "my-dataset", version: 2 }"dataset": {"name": "my-dataset", "version": 2}
Platform by IDdatasetId: "ds_abc123""datasetId": "ds_abc123"
CSV filedataset: { 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 evaluators

Inline 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-mini

All values injected via --param are strings — cast them in code as needed.

Platform effects

When a local run completes, the platform records:

WhatWhere
Traces tagged local_experiment, environment: experimentTrace list
Scores grouped under the experiment nameScores tab in trace peek panel
Run with source: LOCALRun Source filter in experiments listing
Automatic baseline comparison against the most recent COMPLETED run in the same experimentSDK 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 varsStored 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.