Skip to main content

Control-plane API (@everdict/api)

The external SaaS surface — a Fastify HTTP server that accepts eval runs and exposes results, on top of everything the runtime provides (capacity-aware + tenant-fair Scheduler, trust-zone isolation, per-tenant secrets/budgets, autoscaling). Runs are asynchronous: submit returns immediately with a runId; the result arrives by polling or webhook.

Endpoints

MethodPathBody / result
GET/methe caller's Principal{subject,workspace,roles,via}
POST/runs{ harness:{id,version}, case:EvalCase, runtime?, webhookUrl? }202 RunRecord (runs:submit). runtimecase.placement.target (same runtime targeting as /scorecards).
GET/runs/:idRunRecord (200) or 404 (runs:read; another member's PERSONAL execution is 404 too — see below)
GET/runsRunRecord[] for the caller's workspace, minus other members' personal executions (runs:read)
GET/runs/:id/trajectorythe run's owned trajectory (P5 rung 1): sealed TraceEvent[] from everdict_trajectories, embed fallback during dual-read — meta.source says which copy served (runs:read)
GET/trajectoriesbrowse the owned evidence ledger (N1 look-inward): sealed metas {runId, source, eventCount, sealedAt}, newest first, cursor-paginated — Settings › Traces' primary section reads this (runs:read; MCP list_trajectories)
GET/PUT/workspace/trace-thresholdsE4 perception config: bounds evaluated over every trajectory at seal — a crossing lands trace.threshold_crossed on the event log (read runs:read, write settings:write; MCP get/set_workspace_trace_thresholds)
GET/PUT/workspace/trace-ingestionN3 admission lane: the OTLP door's events/hour quota (workspace override > operator EVERDICT_INGEST_MAX_EVENTS_PER_HOUR > unlimited) + last-hour usage + retention TTL; past the bound the door 429s and lands trace.ingestion_throttled (read runs:read, write settings:write; MCP get/set_workspace_trace_ingestion)
POST/v1/tracesOTLP/HTTP door (N0): standard OTLP JSON; spans group by everdict.run_id and seal in the owned TrajectoryStore — point OTEL_EXPORTER_OTLP_ENDPOINT here with a tenant API key in the headers (runs:submit)
POST/sandboxessandbox session run (P6): boot an environment image (or an adopted environment capability, or a registered harness — the playground: warm-installed once, then test cases drive it) as a long-lived container — a Run{kind:sandbox, lifetime:session} born running with a hard TTL on the row; opt-in via EVERDICT_SANDBOX_DRIVER=docker, per-tenant caps → 429 (runs:submit; MCP create_sandbox)
GET/sandboxes (+/:id)the reattach surface: live sessions with record + live meta (expiresAt, busy, booted harness, task summaries) — what the web playground panel polls (runs:read; MCP list_sandboxes/get_sandbox)
POST/sandboxes/:id/tasksplayground test case: run the session's harness on an ad-hoc prompt (no dataset, no graders) → 202 child Run{kind:eval, class:interactive, group→session}, fresh workdir per case in the warm container; one at a time (409), budget admission (402) (runs:submit; MCP submit_sandbox_task) — see docs/architecture/harness-playground.md
GET/sandboxes/:id/tasks/:taskId/tracethe 2s live cursor (?since= index into the task's append-only buffer; omitted = full replay) — a streaming harness shows tool calls mid-run; after settle the sealed trajectory serves the same events, done:true stops the poll (runs:read; MCP read_sandbox_task_trace)
POST/sandboxes/:id/execone sh -c command into the live session, creator-or-admin BEFORE it runs; every exec lands on the session trajectory, sealed at teardown and served by GET /runs/:id/trajectory (runs:read; MCP sandbox_exec)
POST/sandboxes/:id/closetear down (dispose in a finally; an in-flight task is aborted and settles failed{CANCELLED} with partial evidence sealed), seal the trajectory, settle succeeded with session.closedReason — idempotent; a handle lost to a restart settles as orphaned (runs:read; MCP close_sandbox)
POST/datasetsregister a Dataset (immutable → 409) (datasets:write, member+)
POST/datasets/validatedry-run: schema + existing versions/conflict, no write (datasets:write)
GET/datasetsworkspace-owned + _shared datasets (datasets:read)
GET/datasets/:id/versions/:versionfull Dataset incl. cases; version may be latest (datasets:read)
DELETE/datasets/:id/versions/:versionsoft-delete one version (tombstone, data preserved); creator or admin (datasets:delete); exact version required → 404/403
GET/datasets/:id/diff?base=&candidate=version diff: added/removed/changed cases + meta; base/candidate may be latest (datasets:read)
POST/judgesregister a JudgeSpec (model | harness; immutable → 409) (judges:write, member+)
POST/judges/validatedry-run: schema + existing versions/conflict, no write (judges:write)
GET/judgesworkspace-owned + _shared Agent Judges (judges:read)
GET/judges/:id/versions/:versionfull JudgeSpec; version may be latest (judges:read)
POST/runtimesregister a RuntimeSpec (local | nomad | k8s; immutable → 409) (runtimes:write)
POST/runtimes/validatedry-run: schema + existing versions/conflict, no write (runtimes:write)
GET/runtimesworkspace-owned + _shared execution runtimes (runtimes:read)
GET/runtimes/:id/versions/:versionfull RuntimeSpec; version may be latest (runtimes:read)
POST/scorecards{ dataset, harness, judges?, runtime? }202 ScorecardRecord(queued) (scorecards:run, member+)
POST/groupsrun an experiment (ungraded phase-1 group, execution-model P1): { harness, dataset | task:{prompt}, trials?, runtime? }202 ScorecardRecord(kind:"experiment") — graders stripped, no judges/verdict; excluded from leaderboard/trend/analysis (scorecards:run)
GET/groups/:idthe group record with hydrated detail — same record as /scorecards/:id, kind-aware name (scorecards:read)
POST/groups/:id/scorephase 2 detached (P2): { judges[] }202 — judge an existing group's runs, re-write the aggregate; re-score replaces a judge's verdicts, scoring an experiment promotes it (scorecards:run)
GET/ops/driver/:family/:idDriver ops surface v0: describe the durable Temporal driver by LEDGER id (family = batch|score) — status, history pressure, pending activities with last failure (runtimes:read; 404 without a Temporal address)
POST/ops/driver/:family/:id/cancelcooperatively cancel the durable driver (ledger settles via the CP's own guards) (runtimes:control, admin)
GET/approvalsdurable agent approvals (A6): the workspace's parked agent mutations, ?status= filter — an ask survives an agent-service restart (agents:read)
POST/approvals/:id/decide{ decision: approve|deny } → settles exactly once (409 after); a LIVE wait gets it delivered (delivered:true), a dead park RESUMES as a continuation turn (resumed:true) (agents:write)
POST/scorecards/ingest{ dataset, harness, traces:[{caseId,trace:TraceEvent[]}], judges? }202 (no harness run; push) (scorecards:run)
POST/scorecards/ingest/pull{ dataset, harness, source:{kind:otel|mlflow,endpoint,authSecret?}, runs:[{caseId,runId}], judges? }202 (pull from tenant OTel/MLflow; authSecret=SecretStore key) (scorecards:run)
GET/scorecardsScorecardRecord[] (summary only, no heavy per-case results) (scorecards:read)
GET/scorecards/:idfull ScorecardRecord (incl. per-case scorecard) or 404 (scorecards:read)
GET/scorecards/diff?baseline=&candidate=ScorecardDiff (metric Δ + regressions/improvements) (scorecards:read)
POST/keysself-serve issue an API key { label?, scopes? }201 { apiKey } (plaintext once; scopes = read|write|admin, omitted = Full Access/admin) (keys:write, admin)
GET/keysAPI key metadata { id, prefix, label?, scopes?, createdAt }[] — never the plaintext/hash; scopes absent = Full Access (keys:read, admin)
DELETE/keys/:idrevoke a key → 204 (tenant-scoped; foreign id is a no-op) (keys:write, admin)
GET/workspace/pulsethe home screen's ONE read: state now (open/regressed issues · active cycles + their commitment · goals & projects at risk · open agent tasks + pending approvals · window pass rate vs. the preceding window) + the trend over days (activity per day by axis · issues in vs. out · pass rate per day), folded from the platform-event log. Team-privacy scoped; issues:read, viewer+ (MCP get_workspace_pulse) — see docs/architecture/workspace-pulse.md
GET/membersworkspace members { subject, role, email?, addedAt }[] (members:read, viewer+)
PATCH/members/:subjectchange a member's { role }204 (404 if not a member; 409 last-admin) (members:write, admin)
DELETE/members/:subjectremove a member → 204 (idempotent; 409 last-admin) (members:write, admin)
POST/invitesmint a reusable join link { role, expiresInHours? }201 { …, token } (plaintext inv_… once) (members:write, admin)
GET/invitesactive invite links (meta only, no token/hash; incl. acceptedCount) (members:write, admin)
DELETE/invites/:idrevoke an invite link → 204 (tenant-scoped) (members:write, admin)
POST/invites/acceptredeem { token }{ workspace, role } (authenticated human only, not workspace-gated; reusable until expired/revoked)
GET/healthz{ ok: true }

Scorecards are batch evals (a dataset × a harness@version → aggregated Scorecard + per-metric summary), async like runs. See scorecards.md.

Identity is resolved by the auth core (@everdict/auth): Authorization: Bearer <jwt|ak_…> → a Principal{subject, workspace, roles, via} (OIDC/Keycloak JWT or API key). With EVERDICT_REQUIRE_AUTH=1 a missing/invalid credential is 401, otherwise dev falls back to the x-everdict-tenant header (admin). The resolved workspace (= tenant = trust-zone) keys fairness, quotas, isolation, secret scoping, budgets — and scopes every read; roles gate every route (viewer/member/admin). See auth.md. Harness registration (POST/GET /harnesses instances + POST/GET /harness-templates top-level categories; raw config reads GET /harnesses/:id/:version/instance [template ref + pins] and GET /harness-templates/:id/:version [structure] power the web Config panel + new-version prefill; GET /harnesses/:id/diff?base=&candidate= diffs two versions' resolved specs [config changes by field path — e.g. services[x].image; base/candidate may be latest] — harnesses:read), datasets (POST/GET /datasets, workspace-owned + _shared, see datasets.md) and key issuance (admin self-serve POST/GET/DELETE /keys — issued keys carry workspace admin role, narrowable by per-key scopes (read|write|admin, omitted = Full Access), plaintext shown once, hash-only at rest; bootstrap POST /internal/tenant-keys) are covered in tenancy.md.

RunRecord = { id, tenant, harness, caseId, status: queued|running|succeeded|failed, result?, error?, createdAt, updatedAt }. Errors map by AppError.status: budget → 402 BUDGET_EXCEEDED, queue full → 429 RATE_LIMITED, unknown backend → 404, bad body → 400.

Lifecycle (async)

POST /runs ──▶ RunService.submit
├─ budget.admit(tenant) # over-limit → 402 (no run created)
├─ store.create(queued) ───────▶ 202 { runId }
└─ (background) dispatcher.dispatch(job) # Scheduler → Backend → agent
├─ ok → budget.settle(cost); store.update(succeeded, result)
└─ err → store.update(failed, errorEnvelope)
└─ webhookUrl? → POST the final RunRecord
GET /runs/:id ◀── poll until status is terminal (or receive the webhook)

Result store (@everdict/db)

RunStore (create/update/get/list). Default InMemoryRunStore; set DATABASE_URL and the API uses PgRunStore (real Postgres) — it runs migrations at boot (migrate() over packages/db/migrations/, idempotent) and persists RunRecords (result/error as jsonb). Same interface, so the service + lifecycle are unchanged. Migration discipline: docs/migration/.

Run audience — personal executions are their owner's

runs:read says a member may read this workspace's executions; it does not say they may read each OTHER's. An agent run is a conversation turn (the session store has always been owner-scoped) and a sandbox session is somebody's shell, so both are readable only by the member they belong to — origin.actor, else createdBy. Everything else (evals, the playground cases a session runs, analyses) stays workspace-visible, and a personal run with no member stamped on it stays workspace-visible too (hiding evidence from everyone is loss, not privacy). There is deliberately no admin bypass: listing/renaming/deleting a conversation is owner-only, and an admin who could open every member's transcript would make that ownership decorative — what an admin legitimately needs (who spent what) reads off the usage meter, which carries cost without content.

The rule is runAudience/canReadRun in @everdict/domain, applied at three depths so no surface can forget it: RunService.list/getForDisplay/trajectory (both transports inherit it), the run observability routes via runVisible (route-context), and the ledger itself — every sealed trajectory carries an owner (mig 0116, backfilled from the run ledger), so GET /trajectories filters IN the query and GET /trajectories/:id answers 404 for someone else's. Refusals are 404, never 403 — the same answer as another workspace's run, so neither leaks that the row exists.

The trajectory store (the owned trace ledger: eval runs · agent turns · OTLP-door arrivals · materialized imports) is the one store with a second engine: EVERDICT_CLICKHOUSE_URL swaps it to ClickHouseTrajectoryStore (table created at boot) while every other store keeps DATABASE_URL. Compose ships the engine behind --profile clickhouse; the swap copies nothing, so already-sealed trajectories stay in the engine that wrote them. Neighbouring knobs: EVERDICT_INGEST_MAX_EVENTS_PER_HOUR (OTLP-door quota) and EVERDICT_TRAJECTORY_RETENTION_DAYS (ledger retention).

Run it

pnpm build
# local backend (this machine's claude subscription):
PORT=8787 node apps/api/dist/main.js
# distributed backend + per-tenant run cap + Postgres result store:
PORT=8787 NOMAD_ADDR=http://127.0.0.1:4646 EVERDICT_AGENT_IMAGE=<img> EVERDICT_TENANT_RUNS=3 \
DATABASE_URL=postgresql://user:pass@host:5432/db node apps/api/dist/main.js # migrations run at boot

curl -XPOST localhost:8787/runs -H 'x-everdict-tenant: acme' -H 'content-type: application/json' -d '{
"harness": {"id":"scripted","version":"latest"},
"case": {"id":"c1","env":{"kind":"repo","source":{"files":{}}},"task":"...","graders":[{"id":"steps"}],"timeoutSec":120,"tags":[]}
}'
curl localhost:8787/runs/<runId> # poll until "succeeded"

Live-verified end-to-end against real Nomad: POST /runs202 → poll → succeeded with trace + snapshot + scores; a 4th submit past EVERDICT_TENANT_RUNS=3 returns 402 BUDGET_EXCEEDED. With DATABASE_URL set, the succeeded run is confirmed persisted in the everdict_runs Postgres table (survives a server restart).

The CLI (everdict run) is the dev/single-run path; this API is the multi-tenant control-plane surface. Durable orchestration (Temporal) and the API can be combined: point the service's dispatcher at the Temporal orchestrator instead of an in-process Scheduler. See docs/orchestration.md.

The same control plane also serves the agent-facing MCP server at /mcp (run/harness tools, OAuth-protected via Keycloak or API keys, role-gated) plus the OAuth metadata at /.well-known/oauth-protected-resource. See docs/mcp.md.