The REST calls @benchsdk/api makes on behalf of bench run, and how each payload carries its data. Traced from packages/benchsdk-api/src/client.ts + types.ts and the @benchsdk/worker / @benchsdk/runner call sites.
The data model the API moves between a *.bench.ts file and the benchmarks platform. A benchmark run is a tree: benchmark → run → participant → worker → task → step.
| Term | What it is | How it is used |
|---|---|---|
benchmark |
A registered suite identified by a lowercase slug (e.g. sandbox-tti). |
Upserted once per run with its name plus the scoring and display manifests from defineBenchmarkConfig. Everything else hangs off its slug. |
run |
One execution of a benchmark. Carries status, totalTasks, and a snapshot of the resolved run config. |
Created before any task executes. With a shared runKey, sibling processes get-or-create the same run (one CI job per provider). |
participant |
One provider under test inside a run (e.g. e2b, modal). In the bench file: { name, requiredEnvVars, ... }. |
Env-gated locally by requiredEnvVars; registered on the run with its own totalTasks so participant-sized runs know their extent. |
worker |
An execution slot planned under a participant, then claimed by a running process. Owns a taskRange and a targetConcurrency. |
planWorkers creates pending workers; claim binds one to this process and returns an assignment. |
attemptId |
The id of the worker attempt this process is running. | Sent on every worker-scoped mutation (heartbeat, events, artifacts, complete/fail/release) so stale processes cannot corrupt a reclaimed worker. |
task |
One iteration of the workload — one invocation of the function passed to defineTask. Persisted as a TaskResultRecord. |
Runs once per taskIndex in the worker's claimed range. Success/failure, timing, steps, and measured data all land on its record. |
step |
A named, individually timed sub-operation inside a task (ctx.step('create', fn)), persisted as a TaskStepRecord. |
Each step carries status, latency, optional data, and its own concurrency/timeoutMs metadata. Return values are control flow only — never auto-recorded; an outcome-shaped return (stdout/stderr/error/…) is written to the worker log unless captureOutput: false. |
measure(data) |
The explicit metric channel. A JSON object of measurements. | Called inside a step it merges into that step's data; at task top-level it lands on the task record's data. Scoring reads metrics from record.data. |
log |
Human-readable narration (ctx.log(msg, { level, meta })), filtered by BENCHMARK_LOG_LEVEL (default info). |
Buffered per worker and uploaded as a coordinator.log / worker.log artifact — not inline in task records. |
heartbeat |
Periodic liveness + progress report for a claimed worker. | Every ~30s and on step transitions: done/in-flight/error counts, the current step, and per-step concurrency samples used for the run's concurrency timeline. |
artifact |
A file attached to a worker (worker log, system metrics, custom output). | Two-step upload: POST …/artifacts returns a presigned uploadUrl, then the body is PUT directly to it. |
event batch |
An ordered chunk of task records (type: 'task_results') streamed to the run. |
Sent incrementally with a monotonically increasing sequenceNumber; the last batch is isFinal: true. Batches queue for import into the analytics store. |
summary |
The computed scorecard for a run: per-provider metrics, composite score, success rate. | Computed by the runner from config.scoring/onScore and submitted once after all tasks finish; the platform can recompute it from the same stored spec. |
shape / phase / groupBy |
Runner-side orchestration knobs — not API resources. | shapes = named platform identities (slug+name) selected with --shape. phases = named iteration arms tagged into record.data.phase (see Concepts below). groupBy = task ordering ('participant' drives real platform workers; 'round' interleaves participants via per-participant reporters). |
dimensions |
Static run-level key/value tags (e.g. { file_size: '10MB' }), not a runtime channel. |
Copied from config.dimensions into the run config snapshot and onto every results[].dimensions row in the submitted summary (see Concepts below). |
These two config knobs answer different questions, and it matters on the wire:
phases— which arm of the comparison produced this record (input side). Declared asphases: [{ name, iterations }]indefineBenchmarkConfig; mutually exclusive withiterations. Each phase is a named run segment with its own iteration count (e.g.cold/warm, or one phase per file size instorage.bench.ts). The framework owns the phase boundary: it writesrecord.data.phasebefore the task runs, so even a failed record carries its phase and groups correctly with its arm. Inside the task,ctx.phaseexposes the current name so the workload can branch on identity instead of index arithmetic. On the wire, phases appear in two places: theconfig.phasessnapshot sent inPOST …/runs, anddata.phaseon everytask_resultsrecord. CLI note:--iterationsscales each phase equally (phaseIterations) — it does not divide a total between arms — and is ignored with a warning when phases declare uneven iteration counts.dimensions— what distinguishes this result slice (output side).config.dimensionsis a staticRecord<string, unknown>(e.g.{ region: 'us-east-1' },{ file_size: '10MB' }) attached to the run — it changes nothing about scheduling; it tags the submitted summary so runs of the same benchmark that differ by an external parameter stay distinguishable on the dashboard.scoring.groupBy— the bridge. A data key (any key yourmeasure()payloads or the phase tag wrote torecord.data) that splits each participant's records into per-group score rows. Each group row'sdimensions=config.dimensionsmerged with{ [groupByKey]: groupValue }. Example:scoring.groupBy: 'phase'on a phased benchmark emits oneresults[]row per (participant × phase), each taggeddimensions.phase = 'cold' | 'warm'; a benchmark that measuresfile_sizeper record gets one row per size (AWS S3 · 16MB). Caveats:- Despite the name,
scoring.groupByis unrelated to the top-levelgroupBy— the former is a reporting breakout; the latter is execution interleaving ('participant'/'round'). - The breakout only renders when the run produced 2–12 distinct values for the key — one value means no real variation (the platform serves the run blended), more than twelve is unreadable. A run that would produce a single value should drop
scoring.groupBybut keep tagging records — seesingleSizeConfigvsmultiSizeConfiginbenchmarks/storage/storage.bench.ts.
- Despite the name,
In short: phases partition the schedule and tag every record; dimensions label the summary rows. scoring.groupBy turns a record-data key — often the phase itself — into a dimension on the scorecard.
| Concern | Wire format |
|---|---|
| Base URL | https://platform.computesdk.com/api/v1 — override with BENCHMARKS_PLATFORM_URL (root URL; /api/v1 is appended). |
| Auth | Authorization: Bearer <token> where the token is BENCHMARKS_PLATFORM_API_KEY (org-scoped bp_...), BENCHMARKS_PLATFORM_TOKEN (OAuth), or a stored bench auth login token. OAuth requests also send X-Org-Slug or X-Organization-Id. |
| Body | Content-Type: application/json; path params are encodeURIComponent-encoded. Non-2xx → BenchmarkApiError(status, body). |
| Limits | Task-result batches ≤ 5000 records; ≤ 100 steps per record; ≤ 20 unique-step concurrency samples per heartbeat. |
| Idempotency | runKey on run creation (per org + benchmark); attemptId + sequenceNumber on every worker mutation and event batch. |
runBenchmark() issues these calls. In the default groupBy: 'participant' path, runWorker() owns everything worker-scoped; in groupBy: 'round' the runner drives one BenchmarkReporter per participant through the same endpoints by hand.
PUT /benchmarks/{slug}
Materializes the benchmark from the file's identity. Skipped when --benchmark retargets a run at a foreign slug (that would rename someone else's benchmark).
{
"name": "Sandbox TTI (Burst)",
"config": {
"scoring": { /* serializable BenchmarkScoringConfig */ },
"display": { /* metric/step labels, units, overview defaults */ }
}
}POST /benchmarks/{slug}/runs
Two shapes. Self-contained run: declared totalTasks + participants list. Shared run (--run-key): get-or-create by key, participant-sized — no totalTasks/participants; each sibling registers only its own.
{
"runKey": "1234567890",
"totalTasks": 6,
"workerCount": 1,
"participants": ["e2b", "modal"],
"config": {
"benchmarkSlug": "sandbox-tti",
"iterations": 3,
"concurrency": 1,
"staggerDelayMs": 0,
"groupBy": "participant",
"participants": ["e2b", "modal"],
"trigger": { "source": "schedule", "event": "schedule" }
}
}runKey— optional idempotency key (org + benchmark scoped)totalTasks— omit → participant-sized runconfig— resolved run snapshot: slug, name, iterations, phases, concurrency, staggerDelayMs, groupBy, dimensions, scoring, participants, trigger
Returns { run, participants, organizationSlug }; the runner builds the dashboard URL as {base}/{org}/benchmarks/{slug}/runs/{runId}.
PUT /benchmarks/{slug}/runs/{runId}/participants/{participantSlug}
{ "totalTasks": 6 }Only used with --run-key: each sibling process declares just its own provider's task count.
POST /benchmarks/{slug}/runs/{runId}/participants/{participantSlug}/workers
{
"workerCount": 1,
"targetConcurrency": 6
}targetConcurrency in round mode is the full schedule length — the platform reads it as tasks-per-worker. Creates pending worker rows the participant's processes can claim.
POST /benchmarks/{slug}/runs/{runId}/participants/{participantSlug}/workers/claim
{ "processKind": "process", "processKey": "ci-runner-01" }Returns the assignment (or null if nothing is pending):
{
"workerId": "...",
"attemptId": "...",
"attemptNumber": 1,
"participantSlug": "e2b",
"taskRange": { "start": 0, "end": 5, "count": 6 },
"targetConcurrency": 6
}POST /benchmarks/{slug}/runs/{runId}/workers/{workerId}/heartbeat
{
"attemptId": "...",
"progressDone": 3,
"progressInFlight": 1,
"progressErrors": 0,
"progressTotal": 6,
"currentStep": "exec.task",
"concurrency": [ { "step": "create", "active": 4, "target": 10 } ]
}~30s cadence plus immediate sends on step transitions; feeds the run's live progress and concurrency timeline. Round mode also calls reporter.setProgress() after every task.
POST /benchmarks/{slug}/runs/{runId}/workers/{workerId}/events
Incrementally flushed batches (default every 30s / at 1000 pending records / once at the end with isFinal: true):
{
"type": "task_results",
"attemptId": "...",
"sequenceNumber": 0,
"isFinal": false,
"records": [
{
"taskIndex": 0,
"status": "success",
"startedAt": "2026-09-21T10:00:00.000Z",
"completedAt": "2026-09-21T10:00:02.400Z",
"latencyMs": 2400,
"data": { "ttiMs": 2310, "phase": "cold" },
"steps": [
{
"name": "create",
"status": "success",
"startedAt": "...",
"completedAt": "...",
"latencyMs": 1800,
"data": { "createMs": 1800 },
"concurrency": 1,
"timeoutMs": 120000
}
]
}
]
}sequenceNumber— monotonic per attemptstatus—"success"or"error"+errorCoderecord.data— top-levelmeasure()payloads land here;step.data—measure()called inside a step
Mapping from authoring code: ctx.step(name, fn) → a steps[] entry; ctx.measure(d) → step.data (inside a step) or record.data (top level); a thrown TaskError → status: 'error' + errorCode, preserving data and pre-measured steps; a task with no declared steps becomes one implicit 'task' step. The phase tag is written to record.data.phase before the task runs so failed records still group correctly.
POST /benchmarks/{slug}/runs/{runId}/workers/{workerId}/artifacts
{
"attemptId": "...",
"kind": "coordinator.log",
"contentType": "text/plain",
"name": "worker.log",
"metadata": { "sizeBytes": 42110 }
}kind — "coordinator.log", "system-metrics", or a custom kind; contentType — "text/plain" / "application/gzip" / "application/x-ndjson"; metadata.sizeBytes is auto-filled from the body when measurable.
Response carries uploadUrl + artifactId; the body is then PUT straight to the presigned URL with the declared Content-Type. The worker log and the metrics.jsonl system-metrics NDJSON both ship this way at finish (and mid-run when log flushing is enabled). Round mode uploads one shared process-scoped metrics artifact with metadata.scope = "shared-process" and the full participant list.
POST /benchmarks/{slug}/runs/{runId}/workers/{workerId}/{complete|fail|release}
{ "attemptId": "...", "errorCode": "STORAGE_ERROR", "errorMessage": "…" }complete when every task succeeded, fail otherwise (errorCode/errorMessage only on fail), release to abandon a claim. A worker whose final flush failed is failed, not completed.
POST /benchmarks/{slug}/runs/{runId}/summary
{
"run": {
"gitSha": "…",
"gitRef": "main",
"triggeredBy": "schedule",
"nodeVersion": "v24.0.0",
"platform": "linux",
"arch": "x64"
},
"results": [
{
"provider": "e2b",
"dimensions": { "file_size": "10MB" },
"metrics": [
{ "name": "ttiMs", "unit": "ms", "median": 2100, "p95": 2600, "p99": 2900 }
],
"compositeScore": 78.4,
"successRate": 1.0,
"skipped": false
}
],
"scoring": { /* the serializable config.scoring spec, echoed for recompute */ }
}dimensions—config.dimensions+ thescoring.groupByvalue (see Concepts: dimensions and phases)compositeScore— Σ per-metric score × weight × successRate, 0–100
Only sent when the config declares scoring or onScore. A malformed spec (ScoringSpecError) fails the run; transport failures degrade to a warning.
These back the bench data commands (bench runs, bench results, bench artifacts, bench export) and the dashboard.
| Method & path | Returns / params |
|---|---|
GET /benchmarks?limit&offset |
Benchmark list (items). |
GET /benchmarks/{slug} · PATCH |
Benchmark resource / update name, status, config. |
GET /benchmarks/{slug}/runs?limit&offset |
Runs for one benchmark. |
GET /benchmarks/runs?benchmarkSlug |
All visible runs across benchmarks, including subscribed feeds (Daily). |
GET …/runs/{runId} · PATCH |
Run detail / update status or config. |
GET …/runs/{runId}/progress |
Live status: per-participant task counts, worker freshness, per-step ready flags (used by step readiness: 'poll' barriers). |
GET …/participants/{p}/logs?maxLines |
Participant worker logs. |
GET …/participants/{p}/workers · GET …/workers/{workerId} |
Worker rows with progress and concurrency samples. |
GET …/runs/{runId}/artifacts · GET …/workers/{workerId}/artifacts |
Artifact listings. |
GET …/workers/{workerId}/artifacts/{artifactId} |
Artifact metadata + presigned downloadUrl; the body is then fetched from that URL (auto-gunzipped when the content type is gzip). |
GET /benchmarks/{slug}/results?limit&offset&participant&steps&since&until&timeZone |
Cross-run overview with analytics readiness per run; participant/steps are comma lists, since/until accept YYYY-MM-DD (zoned by timeZone, default UTC) or an ISO timestamp. |
GET …/runs — GET /benchmarks/{slug}/runs?limit&offset&participant&since&until&timeZone |
Run listing with the same date/participant filters (no steps); unfiltered lists stay unbounded, filtered ones default to limit=100. |
GET …/runs/{runId}/results?participant |
Aggregate result: overall + per-participant + per-step latency summaries and success rates; participant narrows every aggregate to those slugs. |
GET …/runs/{runId}/results/tasks?bucketSize&failureLimit |
Task-index buckets (p50/p95/max per bucket) plus the failure points — the scatter/bucket view. |
GET …/runs/{runId}/results/timeline?bucketMs |
Event-rate buckets + heartbeat-derived concurrency points over time. |
GET …/runs/{runId}/results/imports |
Event-batch ingestion status (queued/persisted/failed per batch). |
GET …/runs/{runId}/iterations?type=tasks|steps&participant&steps |
Raw per-iteration rows — task records or step records, filterable by participant and step names. |
bench run file.bench.ts --provider e2b --iterations 6
│
├─ PUT /benchmarks/sandbox-tti (identity + scoring/display)
├─ POST /benchmarks/sandbox-tti/runs (runKey?, totalTasks, config)
├─ PUT …/runs/{run}/participants/e2b (shared-run mode only)
├─ POST …/participants/e2b/workers (planWorkers)
│
│ per participant (groupBy 'participant'), or interleaved per round:
│ ├─ POST …/participants/e2b/workers/claim → assignment{workerId, attemptId, taskRange}
│ │ loop over taskRange:
│ │ POST …/workers/{w}/heartbeat (every 30s + on step transitions)
│ │ POST …/workers/{w}/events (task_results batches; last has isFinal)
│ │ finish:
│ │ POST …/workers/{w}/artifacts → PUT body to uploadUrl (worker.log, metrics.jsonl)
│ │ POST …/workers/{w}/complete|fail
│
└─ POST …/runs/{run}/summary (scored results)
With --dry-run / --no-ingest / BENCHSDK_NO_INGEST=1 none of these calls are made — the schedule executes locally and records are kept in memory for onComplete. Platform auth is still resolved up front.