# HTTP API

> Routes, Bearer auth, status codes, run submission schema, provenance upload limits, export headers, and review-asset serving.

- Repository: mupt-ai/self-bench
- GitHub: https://github.com/mupt-ai/self-bench
- Human docs: https://grok-wiki.com/public/docs/mupt-ai-self-bench-62fff2be9bcc
- Complete Markdown: https://grok-wiki.com/public/docs/mupt-ai-self-bench-62fff2be9bcc/llms-full.txt

## Source Files

- `src/api.ts`
- `src/api-main.ts`
- `src/contracts.ts`
- `docs/operations.md`
- `review/src/App.tsx`

---

---
title: "HTTP API"
description: "Routes, Bearer auth, status codes, run submission schema, provenance upload limits, export headers, and review-asset serving."
---

`startApi()` is the Node `http.createServer` process started by `src/api-main.ts` (`node dist/api-main.js` / `bun run start:api`). It binds `SELFBENCH_API_HOST`:`SELFBENCH_API_PORT`, opens a Temporal client, and creates the configured artifact store. It does not run Harbor sandboxes or hold `OPENAI_API_KEY` / `GH_TOKEN`. JSON responses are pretty-printed (`JSON.stringify(..., null, 2)` plus a trailing newline) with `content-type: application/json; charset=utf-8`.

## Process and bind

| Item | Value |
| --- | --- |
| Entry | `node dist/api-main.js` (image `CMD`); dev `bun run dev:api` → `tsx src/api-main.ts` |
| Default bind | `127.0.0.1:8080` |
| Compose | Container `SELFBENCH_API_HOST=0.0.0.0`, published `127.0.0.1:8080:8080` |
| Ready log | `SelfBench API listening on http://<host>:<port>` |
| Shutdown | `SIGINT` / `SIGTERM` close the HTTP server and Temporal connection, then exit `0` |
| Workflow start | `selfBenchRunWorkflow`, `workflowId` = `runId`, `taskQueue` = `SELFBENCH_TASK_QUEUE`, `workflowExecutionTimeout` = `14 days` |

`loadConfig()` refuses to start when the bind host is not loopback and `SELFBENCH_API_TOKEN` is unset. Loopback hosts are `127.0.0.1`, `::1`, and `localhost`.

Local Compose sets `SELFBENCH_API_TOKEN` to `${SELFBENCH_API_TOKEN:-selfbench-local}` because the API process listens on `0.0.0.0` inside the container.

```mermaid
flowchart LR
  subgraph Clients
    CLI["self-bench CLI<br/>SELFBENCH_API_URL"]
    Review["Review UI GET /"]
  end
  subgraph APIproc["API process"]
    HTTP["http.createServer"]
  end
  subgraph TemporalNS["Temporal"]
    WF["selfBenchRunWorkflow"]
  end
  subgraph Artifacts["Artifact store"]
    Local["local directory"]
    GCS["GCS bucket/prefix"]
  end
  Worker["worker process"]
  CLI -->|"Bearer + JSON/JSONL"| HTTP
  Review -->|"optional Bearer on /v1"| HTTP
  HTTP -->|"start / query / cancel / list"| WF
  HTTP -->|"put provenance, openRead export"| Artifacts
  Worker --> WF
  Worker --> Artifacts
```

## Authentication

When `SELFBENCH_API_TOKEN` is unset, `/v1` routes accept requests with no `Authorization` header.

When the token is set, `/v1` routes require `Authorization: Bearer <token>`. The server strips a case-insensitive `Bearer ` prefix and compares the remainder with `crypto.timingSafeEqual` after a length check. Missing, wrong-length, or mismatched tokens return `401` `{ "error": "unauthorized" }`. There is no `WWW-Authenticate` header.

Unauthenticated even when a token is configured:

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/healthz` | Liveness `{ "ok": true }` |
| `GET` | `/` | Review SPA `index.html` |
| `GET` | `/assets/*` | Hashed Vite review assets |

`/v1` data routes stay behind the token. The review HTML can load without credentials; listing runs and downloading exports cannot.

<Warning>
The CLI sends `Authorization` only when `SELFBENCH_API_TOKEN` is set in the client environment. Pointing `SELFBENCH_API_URL` at a token-protected API without exporting the same token yields `401`.
</Warning>

## Route inventory

| Method | Path | Auth when token set | Success | Body limit |
| --- | --- | --- | --- | --- |
| `GET` | `/healthz` | No | `200` | — |
| `GET` | `/` | No | `200` | — |
| `GET` | `/assets/*` | No | `200` | — |
| `POST` | `/v1/provenance?runId=` | Yes | `201` | 100 MiB |
| `POST` | `/v1/runs` | Yes | `202` | 10 MiB |
| `GET` | `/v1/runs` | Yes | `200` | — |
| `GET` | `/v1/runs/:runId` | Yes | `200` | — |
| `POST` | `/v1/runs/:runId/cancel` | Yes | `202` | — |
| `GET` | `/v1/runs/:runId/export` | Yes | `200` | — |

`:runId` must match `^[a-z0-9][a-z0-9-]{2,62}$` (3–63 characters, starts with a letter or digit, lowercase, digits, hyphens). Unknown paths return `404` `{ "error": "not found" }`. There is no `DELETE`, `PUT`, `PATCH`, pagination, or CORS handling.

## Run ID

<ParamField query="runId" type="string" required>
Same regex as the path segment and as `submission.runId`. Used as the Temporal `workflowId`. The CLI default is `sb-<14 datetime digits>-<8 uuid chars>`.
</ParamField>

Reuse of an existing `runId` on `POST /v1/runs` fails at Temporal start and surfaces as `500` with the client error message. Failed generation needs a new ID.

## POST /v1/provenance

Stores the raw request body at artifact key `runs/<runId>/input/provenance.jsonl` with content type `application/x-ndjson`. The API does not parse or validate JSONL.

:::endpoint POST /v1/provenance Store sanitized provenance JSONL
<ParamField query="runId" type="string" required>
Target run ID. Invalid or missing values are Zod errors → `400`.
</ParamField>

**Limits**

- Maximum body: `100 * 1024 * 1024` bytes (100 MiB).
- Oversized body: `500` `{ "error": "request body exceeds 104857600 bytes" }` (not `413`).
- Incoming `Content-Type` is ignored; the stored reference always uses `application/x-ndjson`.

Identical bytes at the same key are idempotent (same SHA-256 and size). Different bytes at the same key fail the artifact store and return `500`.

<ResponseField name="uri" type="string">
`file://<absolute-path>` for the local store, or `gs://<bucket>/<prefix>/runs/<runId>/input/provenance.jsonl` for GCS.
</ResponseField>
<ResponseField name="sha256" type="string">
64 lowercase hex digits of the stored bytes.
</ResponseField>
<ResponseField name="sizeBytes" type="integer">
Byte length of the stored body.
</ResponseField>
<ResponseField name="contentType" type="string">
Always `application/x-ndjson`.
</ResponseField>
:::

<RequestExample>
```bash
curl -sS -X POST \
  -H "Authorization: Bearer $SELFBENCH_API_TOKEN" \
  -H "Content-Type: application/x-ndjson" \
  --data-binary @provenance.jsonl \
  "$SELFBENCH_API_URL/v1/provenance?runId=sb-20260814120000-deadbeef"
```
</RequestExample>

<ResponseExample>
```json
{
  "uri": "file:///var/lib/selfbench/artifacts/runs/sb-20260814120000-deadbeef/input/provenance.jsonl",
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "sizeBytes": 2048,
  "contentType": "application/x-ndjson"
}
```
</ResponseExample>

## POST /v1/runs

Parses the JSON body with the HTTP `submissionSchema`, then builds a `runRequestSchema` value and starts Temporal. Clients do not send `authoring` or `version`; the API stamps those fields.

:::endpoint POST /v1/runs Start a tiered candidate workflow
<ParamField body="runId" type="string" required>
`^[a-z0-9][a-z0-9-]{2,62}$`.
</ParamField>
<ParamField body="repository" type="object" required>
`{ "url": "<absolute URL>", "commit": "<40-hex SHA>" }`. `url` is `z.string().url()`.
</ParamField>
<ParamField body="provenance" type="object" required>
Artifact ref from `POST /v1/provenance`: `uri`, `sha256` (64 hex), `sizeBytes` (≥ 0), `contentType`.
</ParamField>
<ParamField body="candidateCounts" type="object" required>
`easy`, `medium`, `hard`: integers `0`–`100` each. After stamping, `runRequestSchema` also requires the sum to be `1`–`100`.
</ParamField>
<ParamField body="authoringModel" type="string">
Non-empty. Default `gpt-5.6-sol`.
</ParamField>
<ParamField body="selfbenchCommit" type="string" required>
Full 40-hex SHA. Overridden by `SELFBENCH_BUILD_COMMIT` on the API process when that env is set.
</ParamField>

**Stamped workflow input (not accepted from the client)**

```json
{
  "authoring": {
    "provider": "openai-codex",
    "model": "<authoringModel>",
    "reasoningEffort": "high"
  },
  "version": {
    "selfbenchCommit": "<SELFBENCH_BUILD_COMMIT or body>",
    "executionBackend": "<docker|modal from API config>",
    "sandboxImage": "<SELFBENCH_DOCKER_IMAGE or SELFBENCH_MODAL_IMAGE>",
    "schema": 1
  }
}
```

Default JSON body limit is `10 * 1024 * 1024` bytes. Invalid JSON is `400`. Zod failures (including a zero or >100 candidate sum) are `400`. Success is `202` `{ "runId": "<id>" }`.
:::

<RequestExample>
```json
{
  "runId": "sb-20260814120000-deadbeef",
  "repository": {
    "url": "https://github.com/example/repo.git",
    "commit": "0123456789abcdef0123456789abcdef01234567"
  },
  "provenance": {
    "uri": "file:///var/lib/selfbench/artifacts/runs/sb-20260814120000-deadbeef/input/provenance.jsonl",
    "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
    "sizeBytes": 2048,
    "contentType": "application/x-ndjson"
  },
  "candidateCounts": { "easy": 5, "medium": 10, "hard": 5 },
  "authoringModel": "gpt-5.6-sol",
  "selfbenchCommit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
```
</RequestExample>

<ResponseExample>
```json
{
  "runId": "sb-20260814120000-deadbeef"
}
```
</ResponseExample>

```mermaid
sequenceDiagram
  participant CLI as self-bench CLI
  participant API as API process
  participant Store as Artifact store
  participant T as Temporal
  CLI->>API: POST /v1/provenance?runId=
  API->>Store: put runs/{id}/input/provenance.jsonl
  Store-->>API: ArtifactRef
  API-->>CLI: 201 ArtifactRef
  CLI->>API: POST /v1/runs
  API->>T: start selfBenchRunWorkflow
  T-->>API: started
  API-->>CLI: 202 runId
  CLI->>API: GET /v1/runs/{id}
  API->>T: query status + describe
  T-->>API: RunStatus
  API-->>CLI: 200 RunStatus
  CLI->>API: GET /v1/runs/{id}/export
  API->>Store: openRead(export)
  API-->>CLI: 200 gzip + x-content-sha256
```

## GET /v1/runs

Lists Temporal executions with visibility query `WorkflowType = 'selfBenchRunWorkflow'`. No pagination or sort is applied.

<ResponseField name="runId" type="string">
Temporal `workflowId`.
</ResponseField>
<ResponseField name="status" type="string">
Temporal execution status name (`RUNNING`, `COMPLETED`, `CANCELED`, `FAILED`, …), not a `RunPhase`.
</ResponseField>
<ResponseField name="startedAt" type="string">
`startTime` as ISO-8601.
</ResponseField>
<ResponseField name="closedAt" type="string">
`closeTime` as ISO-8601 when the execution is closed.
</ResponseField>

## GET /v1/runs/:runId

Queries Temporal `status` and `describe()`. While the execution is `RUNNING`, or when the query already reports a terminal phase (`complete`, `blocked`, `failed`, `cancelled`), the query payload is returned as-is. Otherwise the API overlays a phase derived from Temporal:

| Temporal `status.name` | Overlaid `phase` |
| --- | --- |
| `COMPLETED` | `complete` |
| `CANCELED` | `cancelled` |
| `RUNNING` | `queued` |
| anything else | `failed` |

If the query fails but `describe()` succeeds, the body is only `{ "runId", "phase" }`. If `describe()` also fails (unknown ID), the outer handler returns `500`, not `404`.

### Status body

<ResponseField name="runId" type="string">
Workflow / run ID.
</ResponseField>
<ResponseField name="phase" type="string">
`queued` · `discovering` · `authoring` · `exporting` · `complete` · `blocked` · `failed` · `cancelled`.
</ResponseField>
<ResponseField name="requested" type="integer">
Sum of the three tier budgets.
</ResponseField>
<ResponseField name="requestedByDifficulty" type="object">
`{ easy, medium, hard }`.
</ResponseField>
<ResponseField name="discovered" type="integer">
Unique candidates collected so far.
</ResponseField>
<ResponseField name="accepted" type="integer">
Tasks with status `accepted`.
</ResponseField>
<ResponseField name="rejected" type="integer">
Tasks with status `rejected`.
</ResponseField>
<ResponseField name="tasks" type="array">
Per-candidate progress. Each item has `taskId`, `candidateId`, `difficulty`, `status`, optional `reason`.
</ResponseField>
<ResponseField name="discovery" type="object">
Optional: `wave`, `totalShards`, `completedShards`, `failedShards`, `candidates`.
</ResponseField>
<ResponseField name="export" type="object">
Optional artifact ref once `buildExport` finishes. Present on a successful `complete` run.
</ResponseField>
<ResponseField name="error" type="string">
Optional failure detail. Overlay may set `error` to `Temporal workflow <status>` when Temporal is failed-like and the query has no `error`.
</ResponseField>

Task `status` values: `authoring`, `auditing`, `validating`, `reviewing`, `repairing`, `infrastructure_failed`, `rejected`, `accepted`.

<Info>
`GET /v1/runs` `status` is Temporal’s name (`CANCELED`). `GET /v1/runs/:runId` `phase` uses SelfBench spelling (`cancelled`).
</Info>

## POST /v1/runs/:runId/cancel

Calls Temporal `cancel()` and returns immediately.

<ResponseExample>
```json
{
  "runId": "sb-20260814120000-deadbeef",
  "cancellationRequested": true
}
```
</ResponseExample>

`202` means the cancel was requested, not that `phase` is already `cancelled`. Poll `GET /v1/runs/:runId` for the terminal phase.

## GET /v1/runs/:runId/export

Streams the artifact referenced by `status.export`. Missing or empty `export` returns `409`:

```json
{
  "error": "run export is not ready"
}
```

Success headers:

| Header | Value |
| --- | --- |
| `content-type` | `status.export.contentType` (exports are stored as `application/gzip`) |
| `content-length` | `status.export.sizeBytes` |
| `content-disposition` | `attachment; filename="selfbench-<runId>.tar.gz"` |
| `x-content-sha256` | `status.export.sha256` (64 hex) |

`openRead` checks size before streaming and hashes the bytes. A mid-stream integrity failure destroys the socket after headers are sent. The CLI requires `x-content-sha256`, writes with create-only `wx`, and deletes the file if the digest mismatches.

## Review-asset serving

Vite builds the review app to `dist/review`. The API maps:

| Request | File | `cache-control` |
| --- | --- | --- |
| `GET /` | `dist/review/index.html` | `no-cache` |
| `GET /assets/...` | `dist/review/assets/...` | `public, max-age=31536000, immutable` |

Resolved paths must stay under the `review` root; escape attempts return `400` `{ "error": "invalid asset path" }`. Missing files return `404` `{ "error": "asset not found" }`.

Content types: `.html` → `text/html; charset=utf-8`, `.css` → `text/css; charset=utf-8`, `.js` → `text/javascript; charset=utf-8`, otherwise `application/octet-stream`.

Only `/` and `/assets/*` are served this way. `GET /index.html` is not a public alias.

The SPA lists `GET /v1/runs`, then loads `GET /v1/runs/:runId` and `GET /v1/runs/:runId/export` in parallel. An optional password field supplies `Authorization: Bearer …` for this tab only. `bun run dev:review` proxies `/v1` to `http://127.0.0.1:8080`.

## Status codes

| Code | When |
| --- | --- |
| `200` | Health, status, list, review asset, export stream |
| `201` | Provenance stored |
| `202` | Workflow started or cancel requested |
| `400` | Zod validation, JSON `SyntaxError`, or invalid review asset path |
| `401` | Token configured and Bearer missing/wrong |
| `404` | Unknown route, or review asset missing |
| `409` | Export requested before `status.export` exists |
| `500` | Everything else, including oversized bodies, artifact conflicts, Temporal client errors, and unknown run IDs |

Error bodies are `{ "error": "<message>" }`. Zod and `SyntaxError` use `error.message`. After headers are sent (export stream), the handler destroys the response instead of writing JSON.

## Clients

| Client | Base URL | Token |
| --- | --- | --- |
| `self-bench` | `SELFBENCH_API_URL` or `http://127.0.0.1:8080` | `SELFBENCH_API_TOKEN` if set |
| Review UI | Same origin as the API (or Vite `/v1` proxy) | Session-only input |

CLI mapping: `run` → provenance + create; `list` → `GET /v1/runs`; `status` → `GET /v1/runs/:id`; `cancel` → `POST .../cancel`; `download` → `GET .../export`. `--output` waits until `phase === "complete"` (poll every 2s) and then downloads. `blocked`, `failed`, and `cancelled` abort the wait.

## What this API does not do

- Collect provenance itself (the client uploads JSONL).
- Replace rejected candidates or accept a new `runId` on a failed run.
- Delete artifacts or workflows. Remove local volume data or GCS prefixes with operator tooling.
- Expose model, GitHub, or Modal credentials. Those belong on the worker.

## Failure modes

| Symptom | Likely cause |
| --- | --- |
| Process exits at startup: `SELFBENCH_API_TOKEN is required when the API listens beyond loopback` | Bind host is not `127.0.0.1` / `::1` / `localhost` and no token is set |
| `401 unauthorized` | Token configured on the server, missing or different on the client |
| `400` on create | Bad `runId`, non-URL `repository.url`, short commit SHA, candidate sum `0` or `>100`, invalid provenance ref |
| `409 run export is not ready` | Phase is not a completed export (`blocked` / `failed` / in progress) |
| `500 request body exceeds … bytes` | Provenance `>100` MiB or JSON body `>10` MiB |
| `500` on `GET /v1/runs/:id` | Temporal cannot describe that workflow ID |
| SHA-256 mismatch on download | Stream truncated or corrupted; CLI deletes the partial file |

## Related pages

<CardGroup>
  <Card title="Manage runs" href="/manage-runs">
    CLI list, status, cancel, wait, and create-only download against this API.
  </Card>
  <Card title="Authentication" href="/authentication">
    API Bearer token, CLI `SELFBENCH_API_TOKEN`, and worker credential split.
  </Card>
  <Card title="Configuration reference" href="/configuration-reference">
    `SELFBENCH_API_*`, loopback token rule, artifact backends, Temporal fields.
  </Card>
  <Card title="Review exports" href="/review-exports">
    Hosted review UI and local archive inspection.
  </Card>
  <Card title="Export format" href="/export-format">
    `tar.gz` layout and SHA-256 fields the download headers describe.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    `409` export-not-ready, SHA-256 mismatch, and related operator failures.
  </Card>
</CardGroup>
