Skip to content

REST API

Base URL: http://localhost:3001 (dev). Interactive reference (Scalar) at /docs; machine-readable /openapi.json generated from the shared zod contracts.

Every /v1/* route (except /v1/health) accepts either:

  • API key — HTTP Basic auth with publicKey as username, secretKey as password. Scoped to one project. Used by SDKs.
    Terminal window
    curl -u pk-mt-dev:sk-mt-dev http://localhost:3001/v1/metrics
  • Session cookie — Better Auth session (dashboard). The active project is selected via the x-memoturn-project header (defaults to the user’s first project).

Write endpoints require a non-VIEWER role (viewers get 403).

MethodPathDescription
POST/v1/ingestBatched events (trace-create, span/generation-create/update, event-create, score-create). Returns 207 with per-event results: schema-invalid events are rejected individually in errors (id, index, reason) while valid events are accepted — inspect errors to catch silent data loss. Per-event input/output/metadata JSON capped at 1 MB (400 on oversize). Returns 429 when the per-project event rate limit (INGEST_EVENTS_PER_MINUTE) is exceeded; Retry-After header indicates when to retry. The body also accepts an optional sdk: { name, version } identifying the client build — the official SDKs send it, and it feeds GET /v1/usage/sdks. Per-event limits: INGEST_MAX_EVENT_BYTES (1 MB) and INGEST_MAX_JSON_DEPTH (32) — oversize/over-deep events are per-event 400s in the 207 body. Optional Idempotency-Key header (≤128 chars): a retry within 24 h replays the original 207 (Idempotent-Replayed: true) instead of accepting a second batch; 409 while the first is still in flight.
POST/v1/otel/v1/tracesOpenTelemetry OTLP/HTTP (JSON + protobuf) receiver; maps GenAI semconv spans.
POST/v1/otel/v1/logsOTLP/HTTP logs receiver (JSON + protobuf); log records become EVENT observations (e.g. Claude Code prompt/response text).
POST/v1/otel/v1/metricsAlways 501 with a JSON explanation — OTLP metrics are not ingested. Exists so a collector configured with the base endpoint gets a clear answer instead of a 404.
GET/v1/ingest/healthIngest-pipeline health for the ops console: DLQ depth, insert latency, error counters, recent failed batches. OWNER/ADMIN only. Project-scoped: DLQ depth and recent failures cover the caller’s project only.
POST/v1/ingest/dlq/replayRe-enqueue dead-lettered batches from blob onto the ingest queue. Body: { limit? }. OWNER/ADMIN only; audited.
GET/healthLiveness probe (public, unauthenticated) — { status: "ok" }.
GET/auth-configWhich auth methods are enabled (public, unauthenticated) — password/social/magic-link/email-OTP flags the console reads to render the sign-in surfaces the server accepts.
GET/metricsIn-process API metrics (request counts, status classes, per-route latency percentiles, in-flight). Token-gated: returns 404 unless API_METRICS_TOKEN is set, then requires Authorization: Bearer <token>.
MethodPathDescription
GET/v1/tracesPaginated list { data, total, scores } (per-trace score map); paging: page, pageSize (or legacy limit); filters: userId, sessionId, environment, search (matches trace name OR observation input/output content), tag, promptId, scoreName, level, days, plus filter — a JSON-encoded structured filter set (see below).
GET/v1/traces/facetsDistinct filter facet values + counts (environment, name, tags, scores, levels) over the range; params: days, limit, plus active filters (environment, search, userId, tag, scoreName, level) for facet-excluding counts.
GET/v1/traces/histogramTrace volume { interval, buckets } bucketed by hour (ranges ≤ 2 days) or day; honors the trace-list filters (environment, search, userId, tag, scoreName, level, days).
POST/v1/traces/batchBulk action on selected traces: delete, add-to-dataset, or review.
GET/v1/traces/{id}Assembled trace: observations + scores.
GET/v1/traces/{id}/similarTraces semantically similar to this one (cosine over stored embeddings), most-similar first. Params: limit (≤ 50), days. Returns { data } of trace summaries + similarity.
POST/v1/traces/{id}/replayRe-run a stored trace’s input through the LLM gateway and record the result as a new trace. Body: { provider?, model? }. Audited.
POST/v1/traces/{id}/annotateAdd a manual ANNOTATION score to a trace. Body: { name, dataType, value?, stringValue?, comment? }. Audited.
POST/v1/traces/{id}/tagsReplace a trace’s tags (merge-on-write). Body: { tags: string[] }. Audited.
GET/v1/observationsPaginated spans { data, total } — observations as first-class rows, filtered on the observation itself (the span explorer). Paging: page, pageSize (or legacy limit); filters: search (span name OR inline input/output), traceId, type, level, model, environment, days, plus filter (structured filter set over observation columns).
GET/v1/observations/facetsDistinct facet values + counts (names, types, levels, models, environments) for the span explorer; same filters as above, counts facet-excluding.
GET/v1/sessionsPaginated sessions { data, total } (traces grouped by sessionId); paging: page, pageSize (or legacy limit); scoped by days; search filters by sessionId substring.
GET/v1/sessions/{id}/messagesThe session’s traces as a conversation (Memory Explorer): { session_id, messages }, one turn per trace (oldest-first) with input/output and token/cost roll-ups.
GET/v1/live/tracesLive tail (SSE): streams a trace event per trace as it’s ingested, plus ping heartbeats. Best-effort read-side (Redis pub/sub); EventSource clients pass ?project= since they can’t set the switcher header.
GET/v1/usersPaginated end users { data, total } (traces grouped by userId); paging: page, pageSize (or legacy limit); scoped by days; search filters by userId substring.
GET/v1/metricsCost/token/latency rollups by day and model (days query).
GET/v1/metrics/toolsPer-tool analytics — call volume, error rate, and p50/p95/avg latency by tool name (named SPAN observations) over days. The top agent-debugging view.
GET/v1/metrics/cost-breakdownTop spenders: cost rolled up by end user, session, or prompt, ranked by spend. Query: by (user|session|prompt, default user), days, limit.
GET/v1/usageVolume-based usage metering — bytes / events / traces ingested per UTC day, measured on the raw batch before sampling (the GB-ingested billing signal). Returns { total_bytes, total_events, total_traces, byDay }. Query: days (1–365, default 30).
GET/v1/usage/sdksWhich SDK builds this project has ingested from over the window (name, version, events, batches, first/last seen), busiest first. Populated from the optional sdk field on the ingest body — senders that don’t identify themselves are absent, which means unknown, not zero. Query: days (1–365, default 30).
POST/v1/metrics/queryRun a dashboard/widget analytics query (view × metrics × dimensions × time × filters) from a JSON body; returns result rows. Read-only.

Structured trace filters (filter). /v1/traces, /v1/traces/facets, /v1/traces/histogram, and /v1/exports/traces also accept filter — a URL-encoded JSON array of { column, type, operator, value } predicates, ANDed together, alongside the quick filters above. Columns: name, environment, type, level, tags, userId, sessionId, version, release, timestamp, tokens, cost, latencyMs, metadata, scores, scoreCategories. The last three are key/value columns and carry a key: for metadata the key is a JSON key; for scores (numeric value) and scoreCategories (categorical stringValue) the key is the score name. Score predicates resolve level-agnostically — a trace matches when the score is attached to the trace itself or to any of its observations.

filter=[{"column":"scores","type":"numberObject","key":"accuracy","operator":"lt","value":0.5}]
MethodPathDescription
GET/v1/promptsList prompts with channels + latest version.
POST/v1/promptsCreate a new version; labels point channels at it.
GET/v1/prompts/{name}/detailAll versions + channels (incl. A/B split state per channel).
GET/v1/prompts/{name}/costsSpend attributed to each version (observations grouped by prompt_version), ranked by cost. Param: days.
GET/v1/prompts/{name}/arm-scoresPer-A/B-arm score means (scores grouped by the prompt version that produced them). Param: days.
POST/v1/prompts/{name}/experimentStart a weighted A/B split on a channel. Body: { channel, splitVersion, splitWeight } (1–99%). Audited.
POST/v1/prompts/{name}/experiment/stopStop the experiment on a channel; { channel, promote? } (promote makes the challenger live). Audited.
POST/v1/prompts/{name}/compileResolve a prompt and fill it: composition expanded, {{variables}} substituted, and placeholder slots replaced with the caller’s message lists. Body: { channel?, bucketKey?, variables?, placeholders? }. Writes nothing, so it is not read-only gated. 422 when references can’t be resolved.
GET/v1/prompts/{name}?channel=&bucketKey=Resolve a deployed prompt (SDK path). bucketKey (session/user id) sticks a caller to one A/B arm. Prompt references (`@@@memoturnPrompt:name=X
MethodPathDescription
GET / POST/v1/datasetsList / create.
GET/v1/datasets/{name}Items + runs.
GET/v1/datasets/{name}/comparisonCompare a dataset’s runs side by side (per-item output + scores). Optional version scopes to runs of one dataset version.
GET / PUT/v1/datasets/{name}/schemaThe dataset’s item contract{input?, expectedOutput?, metadata?} object schemas (a subset of JSON Schema: type, required, enum). PUT rejects a schema it can’t honor (unknown type, empty enum, a required field not declared in properties) with the specific problems. Existing items are not re-validated. Audited.
GET/v1/datasets/{name}/schema/checkHow many existing items would fail the current schema, with the first 50 reasons — so tightening a contract is an informed decision rather than a surprise.
POST/v1/datasets/{name}/items/csvImport items from CSV text with a column→field mapping: { csv, mapping: { input: string | string[], expectedOutput?, metadata? } }. Quoted commas, embedded newlines, escaped quotes and a BOM are handled. A mapping naming a column that isn’t in the file fails the whole import (400); per-row problems come back in errors alongside the rows that did import. Audited.
POST/v1/datasets/{name}/itemsAppend items.
POST/v1/datasets/{name}/runsRecord an experiment run (link items → traces). Optional version pins the run to a dataset version (defaults to current).
POST/v1/datasets/{name}/runs/{runId}/gateCI quality gate: aggregate a run’s scores and check them against thresholds ({ scoreName: { min?, max?, maxRegression? } }; optional baselineRun for regression). Returns { passed, failures[], scores[] } for a CI exit code. Read-only.
GET/v1/datasets/{name}/versionsList a dataset’s immutable version snapshots.
POST/v1/datasets/{name}/versionsCut a new version (freeze the current items). Body: { label?, description? }. Audited.
GET/v1/datasets/{name}/versions/{version}A version’s frozen items.
GET/v1/datasets/{name}/exportJSONL download. format=items (backup dump), format=oai-chat (OpenAI fine-tuning chat lines), or format=anthropic-messages (Anthropic fine-tuning; the system prompt is hoisted to a top-level system field). Items without expectedOutput are skipped in both fine-tuning formats; count in X-Memoturn-Skipped. Optional version=N exports a frozen version. Offloaded payloads are rehydrated.
MethodPathDescription
POST/v1/playground/chatOne-shot completion. trace:true (default) records it as a trace. Write-gated (spends the project’s provider key — VIEWERs get 403); maxTokens is capped at PLAYGROUND_MAX_TOKENS (default 32768).
POST/v1/assistant/chatIn-app assistant: a bounded agentic loop over the project’s read-only MCP tools. Body: provider, model, messages[], optional context (organization/project/page/rangeDays); returns {content, steps[]}. Never mutates project data, but write-gated because each turn spends the provider key (VIEWERs get 403).
POST/v1/assistant/streamStreaming assistant (SSE): same loop and body as /chat, but tool steps are emitted as they execute and answer text arrives incrementally (data: {"step":...} | {"delta":...} then [DONE]). Write-gated like /chat.
POST/v1/playground/streamStreaming completion (SSE: data: {"delta":...} then [DONE]). Same body, validation, and write gate as /chat.
MethodPathDescription
GET / POST/v1/evaluatorsList / create (supports online, samplingRate, filterName, scope, variableMapping). kind: "CODE" + expression creates a deterministic check instead of an LLM judge; an expression that doesn’t compile is a 400. scope is trace (default), observation (score individual spans; filterName then matches the SPAN name), or thread. variableMapping binds judge-prompt variables to sources — [{ variable, source, observationName?, jsonPath? }], where source is one of `trace.input
GET/v1/evaluators/analyticsPer-evaluator EVAL score summary (avg, count) + daily trend (days query, default 30).
GET/v1/evaluators/templatesThe prebuilt evaluator library (RAG/quality judge templates).
GET/v1/evaluators/presetsThe prebuilt code-evaluator checks (regex, JSON shape, length, exact match).
POST/v1/evaluators/test-expressionDry-run a code-evaluator expression against a sample item. Persists nothing, so it is not read-only gated. Body: { expression, input?, output?, expectedOutput?, metadata? }.
POST/v1/evaluators/from-templateInstantiate a template into a project evaluator. Body: { key, name?, provider?, model?, ... }. Audited.
GET/v1/evaluators/{name}/versionsImmutable judge-config version history (newest first). A version bumps when the prompt/model/provider changes, so online score drift is attributable to a config change.
GET/v1/evaluators/backfillsRecent evaluator backfill runs with progress counters (name query scopes to one evaluator).
GET/v1/evaluators/backfills/previewHow many traces a backfill would score: { matches }. Query: days, filter (the same JSON-encoded structured trace filter set the traces list uses).
POST/v1/evaluators/{name}/backfillQueue a run of this evaluator over ALREADY-ingested traces. Body: { days?, filters? }. Capped at 5000 traces per run (the cap is recorded on the run, never silent). Thread-scope evaluators are a 400. Audited.
POST/v1/evaluators/{name}/runRun over a trace’s input/output → writes an EVAL score.

Server-executed experiments run a prompt/model across a dataset and auto-score each item (a BullMQ job on the worker); results surface through the dataset comparison grid.

MethodPathDescription
GET / POST/v1/experimentsList / create + enqueue. Create body: { datasetName, name, provider?, model, params?, promptName?, promptChannel?, evaluators? }. Audited.
GET/v1/experiments/{id}Config, progress counters, and per-item results.
GET/v1/experiments/{id}/comparisonThe experiment’s results as an items × runs grid.
POST/v1/experiments/{id}/cancelCancel a pending/running experiment. Audited.
MethodPathDescription
GET/v1/retrieval/analyticsCross-trace RAG diagnostics: similarity histogram, weakest retrievals (worst top-score first), and per-document hit stats. Query: days (1–365), limit (1–200).
GET/v1/embeddings/projection3D UMAP projection of observation embeddings (seeded/deterministic; PCA fallback for small sets or EMBEDDING_PROJECTION_METHOD=pca), with clusters + optional colorBy score. Computed by the daily worker cron. Params: runId?, colorBy?, limit?.
POST/v1/embeddings/projection/runRecompute the projection on demand (instead of waiting for the daily cron). Audited.
MethodPathDescription
GET / POST/v1/review-queuesList / create.
GET/v1/review-queues/analyticsPer-queue review throughput (pending/done/skipped totals).
POST/v1/review-queues/{name}/itemsEnqueue traces.
GET/v1/review-queues/{name}/itemsPending items with trace input/output.
POST/v1/review-queues/{name}/items/{itemId}/assignAssign an item to a user (empty assigneeId unassigns; defaults to self).
POST/v1/review-queues/{name}/items/{itemId}/scoreSubmit a human ANNOTATION score.
POST/v1/review-queues/{name}/items/{itemId}/skipSkip an item without scoring it (marks it SKIPPED).

| GET / PUT / DELETE | /v1/datasets/{name}/runner | The dataset’s remote runner — a URL we POST a signed trigger to so an external eval harness can execute the run. PUT registers or replaces it and returns the HMAC signing secret once (re-registering rotates it); the secret is never read back. Audited. | | POST | /v1/datasets/{name}/remote-runs | Ask the registered runner to execute this dataset. Body: { runName, version? }. Returns 202 with { accepted, status, error, itemCount } — whether the runner accepted the trigger, not whether the run finished. The run row is created before the trigger fires, so a run that never reports back is visibly empty rather than absent. Audited. | | POST | /v1/dataset-run-items | Attach a trace to a dataset item within a run — how an external harness reports results. Body: { datasetName, runName, datasetItemId, traceId }. Creates the run on demand and upserts the link, so a retried report overwrites rather than duplicating. |

MethodPathDescription
GET / POST/v1/providersList (masked) / add an encrypted provider connection. Body: { provider, apiKey?, baseUrl?, region? }. Providers: anthropic, openai, gemini, bedrock (needs region), azure (needs baseUrl), openai_compatible (needs baseUrl; covers vLLM/Ollama/OpenRouter). Credentials stored as an encrypted JSON config blob.
MethodPathDescription
GET / POST/v1/widgetsList (with computed data series; ?dashboardId= scopes to one dashboard, omitted = the Default) / create. Widget config: metric (cost|tokens|generations|latency_p95|error_rate|score), breakdown (by_day|by_model|by_user|by_session), days, filters ({environment?, model?, tag?}), dashboardId?.
GET / POST/v1/widgets/queryList / create query-engine widgets (built in Explore). Create body: title, query (an analytics query), chartType (line|bar|horizontal_bar|big_number|pie|table), dashboardId?, gridW?/gridH?.
PATCH/v1/widgets/{id}/gridPersist a widget’s 12-col grid placement (gridX/gridY/gridW/gridH, all optional).
DELETE/v1/widgets/{id}Delete a dashboard widget (legacy or query-engine).
GET / POST/v1/dashboardsList the project’s named dashboards / create one ({ name }). The “Default” dashboard is implicit (widgets with a null dashboardId).
DELETE/v1/dashboards/{id}Delete a dashboard (its widgets are removed too).
GET / POST/v1/score-configsList / create-update a score config.
DELETE/v1/score-configs/{id}Delete a score config.
GET/v1/scores/namesScore names observed in the window with their data type, source, and count — the analytics picker. Query: days (default 30).
GET/v1/scores/analyticsOne score’s distribution: summary statistics, a 10-bucket histogram (numeric) or label counts (categorical), and a daily timeline. Query: name (required), days.
GET/v1/scores/agreementAgreement between two score sources over the traces carrying both. Numeric pairs return correlation + MAE/RMSE; label pairs return agreement rate, Cohen’s Kappa, and per-label F1. Always returns a confusion matrix (numeric values are bucketed). Query: a, b (required), days. Scans at most 20 000 pairs; past that sampled is true.
PATCH/v1/scores/{id}Correct a score’s value/stringValue/comment (inserts a replacement row; audited).
DELETE/v1/scores/{id}Hard-delete a score (Doris DELETE, project-scoped).
DELETE/v1/traces/{id}Delete one trace completely: every telemetry table, the Postgres state mirror, and the offloaded payload objects it references. Audited. (Raw event batches are multi-trace and governed by retention — follow an erasure with a retention cutoff.)
DELETE/v1/users/{userId}/dataRight to erasure for an END USER of the traced app: every trace recorded under that userId, with the same completeness as DELETE /v1/traces/{id}. Admin-only; audited; returns { traces }.
GET / POST/v1/saved-viewsList / save a table view (named set of filters).
PATCH / DELETE/v1/saved-views/{id}Update a saved view’s name/state / delete it.
GET / POST/v1/commentsList comments on an object (trace/observation/session/prompt) / add one.
GET/v1/comments/mentionsComments in this project that @mention the signed-in user.
DELETE/v1/comments/{id}Delete a comment.
GET / PUT/v1/notification-preferencesRead / update the signed-in user’s notification preferences (per-user, not per-project).

Webhook and automation target URLs are SSRF-validated on write: private IP ranges, loopback addresses, and cloud metadata endpoints are rejected with 400 (override with ALLOW_PRIVATE_WEBHOOK_TARGETS=1 for dev/LAN). The same check runs again at dispatch time to guard against DNS rebinding. Webhook deliveries carry X-Memoturn-Signature: sha256=<hmac> (HMAC-SHA256 of timestamp.body using the webhook secret) and X-Memoturn-Timestamp; the secret is returned once on creation and never again.

MethodPathDescription
GET / POST/v1/webhooksList (includes lastStatus/lastError/lastAttemptAt/failureCount delivery tracking) / create a webhook (POSTs on an event; score.created supports a low-score threshold). secret returned once on 201.
DELETE/v1/webhooks/{id}Delete a webhook.
GET/v1/webhooks/{id}/deliveriesA webhook’s recent delivery log (historical; newest first). Query: limit (≤200).
GET / POST/v1/automationsList / create a trigger→action automation. Triggers: score.created, trace.created, eval.completed, prompt.created, prompt.updated, prompt.label.moved. Actions: webhook, slack, pagerduty, email, github (a repository_dispatchtarget is owner/repo and secret is a PAT with repo scope, stored encrypted and never returned). Optional threshold (score triggers) and filter (substring on the entity name). Audited.
DELETE/v1/automations/{id}Delete an automation.
GET / POST/v1/alertsList / create a stateful alert rule. A worker cron evaluates metric (error_rate/latency_p95/cost_per_day/ingest_volume/dlq_depth) over a trailing window (minutes) against threshold per comparator (gt/gte/lt/lte), notifying channels ([{ type, target }]; type = slack/webhook (URL, SSRF-validated), pagerduty (Events-API routing key; auto-resolves), or email (address; needs an email transport configured)) once on firing and once on resolve.
PATCH / DELETE/v1/alerts/{id}Update (e.g. toggle enabled, adjust threshold/channels) / delete an alert rule.
GET / PUT / DELETE/v1/budgetsGet / set / remove the project’s monthly cost budget (monthlyUsd + thresholds steps, default 50/80/100%). Notifies channels as month-to-date spend crosses each step. Soft only — no hard caps.

Multimodal attachments (images, audio, files). Inline base64 data URIs in trace/observation input/output are offloaded to blob storage at ingest time and replaced with a memoturn-media://<key> reference, so large payloads never bloat Doris; the console fetches them back through the GET route. Both routes require auth and are project-scoped.

MethodPathDescription
POST/v1/mediaStore a base64 data URI ({ "dataUri": "data:<mime>;base64,…" }). Returns 201 with { key, mimeType, url }.
GET/v1/media/{key}Fetch raw bytes back with the stored content-type (immutable, long-cache). 404 if the key isn’t in the caller’s project.
GET/v1/payloads/{key}Fetch a large input/output payload that was offloaded to blob at ingest (> 256 KB). The trace stores a { "_truncated": true, "ref": "memoturn-blob://<key>", "preview": … } marker; this returns the full serialized value. Project-scoped — 404 if the key isn’t payloads/<projectId>/….
MethodPathDescription
GET/v1/projectsProjects the caller can access (with role).
POST/v1/projectsCreate a project in the caller’s active organization. OWNER/ADMIN only; audited.
PATCH/v1/projects/{id}Rename a project. {id} must be the active project. OWNER/ADMIN only; audited.
DELETE/v1/projects/{id}Delete a project and its data (relational rows cascade; telemetry purged best-effort). The last project in an organization can’t be deleted. {id} must be the active project. OWNER/ADMIN only; audited on the organization.
GET/v1/projects/{id}/membersProject-level RBAC: the project’s org members, each with any per-project role override. {id} must be the active project.
PUT/v1/projects/{id}/members/{userId}Assign/update a user’s role on this project (overrides their org role). OWNER/ADMIN only; audited.
DELETE/v1/projects/{id}/members/{userId}Remove a user’s per-project role override (revert to org role). OWNER/ADMIN only; audited.
GET/v1/audit-logsRecent audit entries.
GET / POST/v1/retentionGet / set retention (days; 0 = keep forever).
POST/v1/retention/applyApply retention now.
GET / POST/v1/samplingGet / set ingest sampling. Head: rate (0–100 = percent of traces kept in the query store; 100 = all, stable per-trace). Tail keep-rules (kept regardless of the head dice below 100): keepOnError, keepLatencyMs, keepMinCostUsd (null = off). Dropped traces stay in blob for replay. Audited.
GET / POST/v1/model-pricesList / create-update custom model price overrides (matched by name pattern, override built-ins).
DELETE/v1/model-prices/{id}Delete a model price override.
GET/v1/exports/tracesDownload traces as NDJSON (application/x-ndjson, default), CSV (?format=csv), or Parquet (?format=parquet, flat one-row-per-trace for BI); honors the trace-list filters: limit, environment, search, userId, tag, scoreName, level, days, filter.
GET/v1/exports/traces/{traceId}Download ONE trace as a self-contained JSON document — header, observations (with retrieved documents), and scores, wrapped in a versioned memoturn_export envelope. 404 if the trace is not in this project.
GET / POST/v1/scheduled-exportsGet / configure the recurring daily NDJSON export of traces to blob storage.
POST/v1/scheduled-exports/runRun the export now and write the NDJSON to blob storage.
GET / POST/v1/maskingGet / configure the PII redaction policy (built-in + custom patterns) applied to trace input/output at ingest.
POST/v1/guardrails/checkRuntime guardrails: scan { text } for PII / prompt injection / SQL injection / blocked terms / required-match / JSON shape, plus opt-in LLM guards — evaluator (judge) guards and built-in restricted-topic + toxicity model guards; returns { verdict: allow|redact|block, findings, redactedText? }. Read-only compute (the LLM guard calls write nothing and fail open on timeout/error). SDK: checkGuardrails / check_guardrails.
GET / POST/v1/guardrailsGet / configure the project’s guardrail policy (PII action, prompt-injection/SQL-injection detection, blocked terms, requireMatch, requireValidJson/requiredJsonKeys, evaluator-backed evaluatorGuards, and model guards restrictedTopics/toxicity+toxicityThreshold judged by judgeProvider/judgeModel).
GET / POST/v1/analytics-sinkGet / configure the event sink — forwarding trace/score events to a product-analytics/CDP endpoint (PostHog-compatible capture API). POST host URL is SSRF-validated (400 on private/loopback targets).
GET / POST/v1/api-keysList project API keys (public key + hint) / mint a new pair (secret returned once). Admin-only (OWNER/ADMIN). Scopes: read, write, ingest (the default set) and admin. A key acts as MEMBER unless it carries admin, in which case it acts as OWNER on admin-only routes — never granted by default.
DELETE/v1/api-keys/{id}Revoke an API key. Admin-only.
GET/v1/account/mcp-connectionsList the OAuth clients (remote MCP IDEs/agents) the signed-in user has authorized. Empty for API-key callers (no user).
DELETE/v1/account/mcp-connections/{consentId}Disconnect an OAuth client: deletes the consent and revokes its refresh tokens (access ends when the last ≤1 h JWT expires).
GET/v1/healthLiveness (no auth).
*/auth/*Better Auth (sign-in/out, session).

A remote Model Context Protocol endpoint exposing the project’s prompts, datasets, and review queues as tools for agent IDEs — the same tool registry (packages/server/src/mcp-tools.ts) the local stdio server serves (see MCP), over Streamable HTTP. Each project is its own MCP resource, so clients connect per-project. RBAC is per-tool (not per-method — every call is a POST): a tool’s mutating flag maps to a read/write permission, and write tools are audited.

The endpoint speaks the stateless 2026-07-28 MCP protocol (no initialize handshake; every request is self-contained, so any replica can serve any call) and remains compatible with clients on earlier MCP revisions, which are served through the same per-request path.

Two auth paths resolve to the same per-project authorization:

  • API-key Basic (pk-mt-…:sk-mt-…, self-host / headless) — the key must belong to the {projectId} in the URL; the tool’s permission is checked against the key’s read/write scope.
  • OAuth 2.1 bearer (Memoturn cloud, IDE click-through) — the Better Auth @better-auth/oauth-provider plugin issues a JWT access token (authorization-code flow with mandatory PKCE S256, rotating refresh tokens, dynamic client registration per RFC 7591); the API verifies it statelessly (signature via /auth/jwks, issuer, audience) and resolves its sub to a user, who is then authorized against {projectId} (org membership → role). Any member may run read tools; only non-VIEWER roles may run write tools. Clients discover the flow via the .well-known documents below; an unauthenticated request returns 401 with WWW-Authenticate: Bearer resource_metadata="…".
MethodPathDescription
GET / POST / DELETE/v1/mcp/{projectId}Streamable-HTTP MCP endpoint scoped to {projectId}. 401 (advertising Bearer + Basic) when auth is missing/invalid or the caller isn’t authorized for the project.
GET/.well-known/oauth-authorization-serverOAuth 2.1 authorization-server metadata (Better Auth @better-auth/oauth-provider plugin).
GET/.well-known/openid-configurationOIDC discovery metadata (same plugin).
GET/.well-known/oauth-protected-resourceOAuth protected-resource metadata (RFC 9728) — advertises the API origin as the canonical resource for all per-project MCP URLs.

Behind Caddy (single-VM prod), the three .well-known paths are routed to the API (they’re served at the domain root, not the console) — see infra/Caddyfile. The OAuth authorize flow bounces unauthenticated users to the console sign-in page (MCP_LOGIN_PAGE, default <first AUTH_TRUSTED_ORIGINS>/login) and scope approval to the console consent page (MCP_CONSENT_PAGE, default <first AUTH_TRUSTED_ORIGINS>/consent).