All docs

Eunice REST API Reference

REST endpoints across every router, the streaming event format, and request and response schemas.

Developer reference for every HTTP endpoint mounted by the FastAPI process. All routers are wired in app.py:497-515. Every endpoint that talks to the database or the agent runs through one authentication middleware and one tenant resolver.

ConceptValue
Base URL (dev)http://localhost:8001/api/v1
AuthJWT bearer (Authorization: Bearer <token>); see Auth middleware
Tenant scopeX-Tenant-ID request header; falls back to settings.default_tenant_id
Content typeapplication/json unless explicitly documented as multipart, SSE, or binary
Time formatISO 8601 UTC strings
Cost fieldscost_usd, total_cost_usd, cost, floating-point USD; 0.0 means no LLM call

This page covers all routers. For chat-loop internals see Engine; for the data registry Fabric; for capability mapping Auth & RBAC.

Auth middleware

app.py:320-476 runs in front of every router. Behavior:

  1. Three path sets bypass auth entirely: /api/v1/auth/{register,login,refresh} (public auth), /api/v1/{health,ready,version} (operational probes), and the OpenAPI docs (/docs, /openapi.json, /redoc).
  2. Otherwise the middleware decodes the JWT, validates type == "access", and resolves the active tenant in this precedence: X-Tenant-ID header → JWT tenant_id claim → first membership → settings.default_tenant_id. A header pointing at a tenant the user lacks membership in returns 403 (system_admin is exempt).
  3. Capabilities are resolved via resolve_capabilities(tenant_role, user.system_role, db) and stashed on the per-request RequestContext (see Glossary).
  4. CATALYST_AUTH_DEV_BYPASS=true (default in dev) lets unauthenticated requests through as a synthetic dev@localhost user with SYSTEM_ADMIN_CAPABILITIES. Production safety check at app.py:101-109 refuses to boot if dev bypass is on with a non-loopback bind.
  5. A catch-all handler (_handle_db_error) returns 503 with "Database was re-initialized. Please retry your request." after re-initialising the database. It matches both backends’ missing-table phrasings and catches ProgrammingError as well as OperationalError, which is what PostgreSQL raises. Until 2026-09 it matched only the fallback’s wording and so never fired on PostgreSQL.

Endpoints requiring fine-grained capabilities use the @requires("capability.name") decorator; the middleware sets the context that decorator reads.

Operational endpoints

Public; no auth, no tenant scoping.

MethodPathSourceDescription
GET/api/v1/healthapp.py:518Liveness, process is up. Returns release, sha (12-char prefix), platform.
GET/api/v1/readyapp.py:530Readiness, runs SELECT 1. Returns {status, database}.
GET/api/v1/versionapp.py:542Build identity, release, sha, branch, built_at, optional rolled_back_from. Resolution order: CATALYST_RELEASE/CATALYST_BUILD_SHA/etc env vars → build_info.json next to app.py → live git rev-parse.

Auth

shared/auth_router.py, prefix /api/v1/auth.

MethodPathDescription
POST/registerCreate a user. Requires settings.allow_open_registration. Auto-assigns member role in default_tenant_id.
POST/loginEmail + password → token pair. Pushes a low-priority operational alert on first login of the day or new IP (_notify_login).
POST/refreshExchange a type=refresh token for a new pair.
GET/meCurrent user + sorted capabilities.
POST/switch-tenantReissue tokens scoped to a different tenant. system_admin may switch to any tenant; everyone else must hold membership.

All four token-emitting endpoints return the same TokenResponse:

{
  "access_token": "...",
  "refresh_token": "...",
  "token_type": "bearer",
  "expires_in": 3600,
  "user": {
    "id": "uuid",
    "email": "...",
    "display_name": "...",
    "system_role": null,
    "is_active": true,
    "tenants": [{ "tenant_id": "epa-default", "role": "member" }],
    "active_tenant_id": "epa-default"
  }
}

Token TTLs come from settings.auth_access_token_minutes and settings.auth_refresh_token_days. Email addresses are HMAC-hashed before they hit log lines (_hash_email_for_log).

Assistant, chat + tools

Three routers share the /api/v1/assistant prefix.

Chat loop (engine/orchestrator.py)

MethodPathDescription
POST/chatAgentic chat. Streams Server-Sent Events by default.

ChatRequest body:

FieldTypeDescription
messagestringUser message text.
conversation_idstring|nullExisting conversation UUID; a new one is minted if omitted.
dataset_idstring|nullScope this turn to a specific upload.
complexitysimple|moderate|complexOptional frontend hint.
model_overridestring|nullPer-request override of the configured chat model.
file_contextstring[]Raw text snippets attached to the message (e.g. file previews).
data_refsstring[]Catalog entry IDs picked from the file picker / Fabric. Resolved server-side via catalog.get_entry_by_id().
inference_paramsdictOverride temperature, top_p, etc.
focus_contextstring|nullExtra context injected from focus mode.
agentic_modeboolOpt into the agentic prompt + plan tools + session state.
streamboolDefault true. Set false for a single JSON response.

SSE event types, every event is a JSON object on a data: line. Event ordering is monotonic by sequence.

EventPayloadNotes
status{message}Short progress label for the UI.
thinking{text}Extended thinking content (pacing is RAF-batched on the frontend; see Streaming).
text_delta{text}Incremental assistant text. Coalesced by useDisplayEvents.ts.
tool_call{tool_name, tool_input}Full untruncated input.
tool_result{tool_name, result, duration_ms}Full untruncated output.
progress{message, pct?}Long-running tool progress.
card{type, data}Rich artifact (chart, map, file_download, etc.).
done{cost, tokens_in, tokens_out, model}Terminal event on success.
error{message}Terminal event on failure.

When stream: false the response collapses to {message, cards, metadata} with metadata.cost / tokens_in / tokens_out.

Convened conversations (new, 2026-09)

MethodPathDescription
GET/convened-workersDurable state for a convened agent. Query by worker_id (single) or conversation_id (list, most-recent-first, limit 50). Tenant-scoped; a worker is reachable only through the ACL of the conversation that convened it, no second auth path. Returns 404 (not 403) on an inaccessible worker_id so existence can’t be probed.

Backs tools/convene.py / engine/turn_driver.py, one conversation convening real conversations as workers and exchanging messages with them over N rounds. The card that renders this attaches to the live SSE stream first and falls back to this endpoint once the stream’s replay buffer has been cleared (i.e. the worker has already finished).

Admin/utility routes (engine/admin_routes.py)

Same prefix; carved out of the orchestrator so the chat module stays under its LOC budget.

MethodPathDescription
POST/chat/build-toolSubmit a ChatRequest-shaped body to the ToolJobQueue. Returns {message, cards: [tool_job_status], metadata}.
GET/tools/jobs/{job_id}Poll a build job. Returns the job status dict; 404 if unknown.
GET/toolsList all tools registered for the current tenant.
GET/chat/modelsModels flagged chat_selectable=True in model_config, deduplicated by model_id, with provider, pricing, tier, and an available flag derived from API-key presence.
POST/upload-imageMultipart upload (file). Extensions limited to .png/.jpg/.jpeg/.webp/.gif/.bmp/.tiff/.tif. Returns {file_path, filename, size_bytes, mime_type}. Size capped by settings.max_upload_bytes.

Peer review

Defined in engine/orchestrator.py. Same prefix.

MethodPathDescription
POST/peer-reviewSSE stream from a second model reviewing a conversation. Body: {conversation_id, reviewer_provider, reviewer_model}.
GET/peer-review/modelsModels eligible to act as reviewer.

Downloads (api/downloads.py)

MethodPathDescription
GET/downloads/{file_id}Serve a generated file from the generated_files table. Tenant-scoped; cross-user requires conversation.read.all. Falls back to a legacy glob lookup for pre-table files.
GET/downloads/{file_id}/markdownConvert a .docx to markdown for inline viewing. @requires("analyze").

file_id must match ^[a-f0-9\-]+$; anything else returns 400.

Chart pipeline

Four routers share the /api/v1/assistant prefix and back the v2-insight chart card lifecycle.

api/chart_snapshots.py, auto-capture canvas PNGs from rendered chart cards.

MethodPathDescription
POST/chart-snapshotPersist a chart PNG keyed by (conversation_id, insight_hash). Deduped via deterministic filename, second POST returns {file_id, reused: true}. Caps body at 25 MB, validates PNG magic bytes, and patches ChatMessage.structured_content so Word/PDF exports embed the snapshot.

api/map_snapshots.py, composited live-map PNGs (basemap + deck.gl layers + legend).

MethodPathDescription
POST/map-snapshotPersist a map composite keyed by (conversation_id, display_id). Overwrite-latest: the row is reused and bytes are refreshed each turn as layers accrue.

api/chart_bookmarks.py, user-pinned analytical views (named snapshots) and per-user UI prefs.

MethodPathDescription
POST/chart-snapshotsSave a named bookmark (title, description, insight, setup, thumbnail_file_id). Owner is the calling user.
GET/chart-snapshotsList the caller’s bookmarks. conversation_read.all widens to tenant. Filters: conversation_id, dataset_id.
DELETE/chart-snapshots/{snapshot_id}Soft-delete (sets deleted_at). Owner-only unless conversation.read.all.
GET/chart-prefsReturn the caller’s chart UI prefs dict.
PUT/chart-prefsMerge prefs into the stored row. Keys with null values are deleted.

api/chart_url_states.py, server-side overflow for shareable chart URLs.

MethodPathDescription
POST/chart-url-stateStore an oversized state payload, return {id}. Validates state.shape == "v2-insight".
GET/chart-url-state/{state_id}Resolve a stored state and refresh last_accessed_at (rolling 365-day TTL; GC runs from scripts/background_jobs.py).

Conversations

conversations/router.py, prefix /api/v1/conversations. Conversations are private-by-default; access is gated by check_conversation_access().

MethodPathDescription
GET“List recent conversations (most-recent first, limit 50).
POST“Create a new conversation.
GET/{conversation_id}/messagesFull message history including structured_content (canonical LLM messages).
POST/messagesPersist a message. Backend owns assistant-message persistence; this endpoint is primarily used for user-side saves.
PATCH/{conversation_id}Update title.
DELETE/{conversation_id}Delete a conversation and its messages.
GET/{conversation_id}/replayFull immutable event log with optional filters: message_id, event_types (comma-separated), from_seq, limit (default 10000). Returns {events, total_events, total_cost_usd, tool_calls, errors}.
GET/{conversation_id}/messages/{message_id}/eventsThinking/tool/progress/error events for a single message, used when reloading a conversation.
GET/telemetry/summaryConversation list with telemetry event counts for the Telemetry UI.

Telemetry sorts by (timestamp, sequence), see shared/telemetry.py.

Notifications

shared/notification_router.py, prefix /api/v1/notifications. All endpoints require the notification.read capability except /broadcast which requires notification.broadcast.

MethodPathDescription
GET“List notifications for the caller. Query: unread_only, channel_type, reason, limit (default 50), offset.
GET/unread-count{count} for the badge.
GET/streamSSE stream. On connect, backfills up to 50 unread items; thereafter pushes new notifications. Sends : keepalive every 5 s. Honors the Last-Event-ID reconnect header. Filters server-side by recipient_scope (user/tenant/system).
POST/{id}/readMark read.
POST/{id}/dismissMark dismissed.
POST/{id}/actionMark actioned.
POST/read-allMark every notification for the caller read. Returns {status, count}.
POST/broadcastTenant-wide broadcast. Body is NotificationCreate.

SSE frame format:

id: <notification_id>
event: notification
data: { ... notification JSON ... }

Knowledge

knowledge/router.py, prefix /api/v1/knowledge.

MethodPathDescription
GET/corporaList corpora with document_count and chunk_count.
POST/corporaCreate a corpus. Returns 409 on duplicate name.
POST/uploadMultipart upload (file, corpus_id). Extracts via unstructured for DOCX/PDF; openpyxl/pyxlsb for Excel. Returns {document_id, filename, chunk_count, char_count, duplicate}.
POST/queryDirect RAG with synthesis. Body: {query, corpus_id?, top_k?}. Returns {message, cards: [cited_answer], metadata}. Useful for non-chat integrations.

Predict

predict/router.py, prefix /api/v1/predict.

MethodPathDescription
POST/uploadMultipart upload. Accepts CSV/TSV/XLSX/XLS/XLSB; XLSB is converted via LibreOffice. String columns are auto-stripped on load. Returns dataset id, profile, preview, dtypes, optional sheets/active_sheet.
GET/datasetsList uploaded datasets.
POST/recommendLLM picks algorithm + target. Body: {dataset_id, user_goal?}.
POST/trainTrain. Body: {dataset_id, target_column, feature_columns?, algorithm?, hyperparams?}. Returns metrics, shap_summary, scores, training time, row counts.
GET/modelsList trained models (in-memory + disk-restored on startup).
POST/scoreScore with a trained model. Body: {model_id, dataset_id?}.
POST/explainSHAP explanation for a single row. Body: {model_id, entity_index}.

Insight

insight/router.py, prefix /api/v1/insight.

MethodPathDescription
GET/taxonomiesList classification taxonomies.
POST/taxonomies/{taxonomy_id}/categoriesAdd a category. Body: {label, description, examples?}.
DELETE/taxonomies/{taxonomy_id}/categories/{label}Remove a category.
GET/schemasList extraction schemas.
POST/schemasCreate a schema. Body: {name, description?, fields?, applies_to?}. Name must be unique.
PUT/schemas/{schema_id}Patch a schema. Body: {add_fields?, remove_fields?, update_applies_to?}.
DELETE/schemas/{schema_id}Delete a schema.
GET/historyRecent processing results. Query: limit (default 50).
GET/statsAggregate counts and total_cost_usd.

Apps

apps/router.py, prefix /api/v1/apps. This is the App Platform surface, it replaced the old Workflow feature’s /api/v1/workflow/* surface, which is gone: workflow/router.py, workflow/engine.py, workflow/primitives.py, and workflow/assembly.py no longer exist (retired 2026-07; see the Skills & Primitives note in the project CLAUDE.md). What survives of the old workflow layer is workflow/factory.py (PrimitiveFactory) and workflow/seed_primitives.py, both now built on skills/primitive_runtime.py, see Building primitives.

An “app” here is a versioned, frozen manifest (tools + bindings) that runs under its own principal rather than the acting user’s, not the retired Workflow graph concept.

MethodPathDescription
POST/triggerFire a trigger. Body carries either an inline manifest (draft/preview, runs as the acting user) or app_id (+ optional version) naming a published app (runs under that app’s frozen principal). A suspended app returns a labeled refusal envelope, not an HTTP error.
POST/validateValidate a manifest, returning the invalidation map + derived triggers. Used by the app shell before rendering a draft and by the app factory’s dry-run loop.
GET“List published apps for the tenant.
GET/{app_id}Fetch one app’s current published manifest.
POST/{app_id}/publishPublish a manifest as a new app version.
POST/{app_id}/suspendSuspend an app (subsequent triggers get a refusal envelope).
POST/{app_id}/reactivateReactivate a suspended app.
POST/{app_id}/transferTransfer app ownership.

Auth: a normal authenticated user with tool.execute, the app principal for a published app is resolved server-side from app_id, never supplied by the caller. The chat-facing entry point (build_app tool, tools/factory_tools.py) drafts a manifest via apps/factory.py:build_app_manifest before any of these endpoints are hit.

Fabric

fabric/router.py, prefix /api/v1/fabric. The unified data catalog: tenant uploads (deduped by content_hash), conversation-scoped derived datasets, exports, Knowledge documents, and Kits (semantic dataset groupings). See Fabric module.

MethodPathDescription
GET/datasetsTenant uploads grouped by (name, content_hash).
GET/datasets/{dataset_id}Full metadata.
GET/datasets/{dataset_id}/previewPreview first n_rows (default 10).
DELETE/datasets/{dataset_id}Tombstone, reports how many conversations referenced the same hash.
GET/catalogsAll conversation catalogs with their datasets.
GET/catalogs/{conversation_id}/datasets/{name}/dependentsDependent datasets, for confirmation dialogs.
DELETE/catalogs/{conversation_id}/datasets/{name}Tombstone a conversation dataset. Body {cascade} opt-in tombstones derived datasets.
GET/documentsCatalog documents.
DELETE/documents/{document_id}Hard-delete document + chunks; tombstones catalog entries.
GET/exportsGenerated exports for the tenant. Members see only their own; admins see all.
GET/entries/{entry_id}Resolve a catalog UUID, used by attachment badges.
GET/kitsList Kits.
POST/kitsCreate. Body: {name, description?, dataset_ids?}.
GET/kits/{kit_id}Kit with members (dataset id, name, reason, added_by).
POST/kits/{kit_id}/membersAdd members. Body: {dataset_ids}.
DELETE/kits/{kit_id}/members/{dataset_id}Remove a member.
DELETE/kits/{kit_id}Delete a Kit.
POST/kits/searchBody: {query}. Vector-search backed.
GET/overviewTemperature-sorted view for the Fabric page. Datasets deduped by hash and filtered to durable entry types. Temperature labels: hot (≥70), warm (≥30), cold (≥5), frozen (<5).
POST/dream-cycleRun the dream cycle on demand. Requires tenant.manage. The dream cycle normally runs out of process via scripts/background_jobs.py.

Document deletion is asymmetric: content (rows + chunks) is hard-deleted for storage hygiene, but catalog entries are tombstoned to preserve lineage. The “Tombstone, Never Delete” principle applies to the catalog surface only.

Admin

admin/router.py, prefix /api/v1/admin. Tenant-scoped; sensitive operations gated by capability.

MethodPathDescription
GET/modelsAll model_config rows ordered by role then provider. Includes pricing columns, context_window, max_output_tokens, capability flags.
GET/models/availableDistinct models (deduped by model_id).
GET/models/rolesEach role with its current active model.
PUT/models/role/{role}Assign a model to a role. Body: {model_id, max_output_tokens?}. Deactivates the previous winner. Valid roles: chat, fast, strong, compaction, tool_build_simple, tool_build_complex, tool_audit, peer_review, kit_curator, embedding.
GET/models/role/{role}/chainThe models serving a role in priority order (redundancy chain, get_models_for_role).
PUT/models/role/{role}/chainSet the ordered list of models serving a role. Body: {model_ids}. Models not listed are deactivated for the role rather than deleted. Requires system.models.manage (platform-wide, unlike the single-model assign endpoint).
DELETE/models/role/{role}/overrideClear this tenant’s tenant_model_overrides row for a role, falling back to the system default. Requires tenant.models.manage.
GET/settingsEvery administrator-visible runtime setting, grouped, with current + default value and a withheld_secret_count (secrets are never listed or writable, see shared/settings_surface.py). Requires system.settings.manage.
PUT/settings/{key}Set one override. Body: {value}. Applied immediately for settings in LIVE_SETTINGS, otherwise on next restart.
DELETE/settings/{key}Drop an override, reverting to the environment/default value.
GET/memory/censusPer-store memory residency snapshot (namespaces, catalogs, SQL engine, session streams, async jobs), each section reports independently so one store’s failure doesn’t 500 the whole census.
GET/mcp/connectionsList MCP connections with live status.
POST/mcp/connectionsAdd a connection. Body: MCPConnectionCreate (name, transport, plus stdio or HTTP fields).
PUT/mcp/connections/{name}Update a connection.
DELETE/mcp/connections/{name}Soft-delete and disconnect.
POST/mcp/connections/{name}/connect(Re)connect.
POST/mcp/connections/{name}/disconnectDisconnect.
GET/mcp/marketplaceSmithery.ai search proxy (avoids browser CORS). Query: q, pageSize, page, verified, remote, isDeployed.
GET/mcp/marketplace/{qualified_name}Server detail proxy.
GET/usersUsers in the current tenant. Requires tenant.users.manage.
POST/usersAdd an existing user to the tenant. Body: {email, role?}.
PUT/users/{user_id}/roleChange role. Body: {role}.
DELETE/users/{user_id}Remove membership (does not delete the user account).
GET/async/statusPool state for the AsyncExecutionService, worker counts, circuit breaker, dead letter queue.

FinOps

shared/finops_router.py, prefix /api/v1/finops. Every endpoint requires finops.read; tenant-wide endpoints additionally require finops.read.all. Cross-tenant access requires finops.read.cross_tenant. Members see only their own rows (plus NULL-user rows for background work they triggered) unless they hold finops.read.all.

MethodPathDescription
GET/spend/{tenant_id}Spend summary. Query: period=today|week|month|all.
GET/budget/{tenant_id}Budget utilization for the tenant.
GET/summary/{tenant_id}Higher-level summary. Default period=30d.
GET/timeseries/{tenant_id}Spend over time. Query: period, granularity=day|..., group_by=null|module|attribution|user. group_by=user requires admin.
GET/by_model/{tenant_id}Spend grouped by model.
GET/by_module/{tenant_id}Spend grouped by module.
GET/overhead/{tenant_id}Background/automation overhead. Admin-only.
GET/by_user/{tenant_id}User leaderboard. Admin-only.
GET/top_conversations/{tenant_id}Most-expensive conversations. limit clamped to 1-200.
GET/anomalies/{tenant_id}Heuristic anomalies. Admin-only. Default period=7d.

A missing DB table on the legacy /spend / /budget paths returns a degraded but valid zero-filled response instead of 500.

Spatial

tools/spatial_router.py, prefix /api/v1. Serves content-addressed blobs produced by mapping tools so the browser fetches map data over normal HTTP instead of through the SSE chat stream. Hashes must match ^[a-f0-9]{64}$. Responses are immutable (Cache-Control: public, max-age=31536000, immutable, ETag: "<sha>", X-Content-Type-Options: nosniff).

MethodPathCapabilityDescription
GET/geojson/{sha256_hex}geojson.readGzipped GeoJSON. Content-Encoding: gzip, Content-Type: application/geo+json.
GET/geoparquet/{sha256_hex}geoparquet.readRaw Parquet bytes (zstd internally). Content-Type: application/vnd.apache.parquet.
GET/blob/{sha256_hex}geoparquet.readType-agnostic gzipped binary used by the volumetric pipeline. Sniffs decompressed magic bytes (PNG, JPEG, GIF, WebP) to set Content-Type; falls back to application/octet-stream.
POST/volume/run_dispersiongeoparquet.readDrives simulate_dispersion through the tool registry and returns {manifest_url, summary, save_as}. Used by the volume lab.

Status codes for blob fetches:

StatusMeaning
200Hit
400Malformed hash
404Blob not found, wrong type, or wrong tenant
410The catalog entry that originally bound the blob is tombstoned AND no other live catalog entry references the same hash

The 410 path matters for back-history: a blob whose original catalog entry was retired can still be served if another live entry points at the same content hash, so a previously-rendered card doesn’t break when the user re-saves a dataset under the same name.

Environmental layers

tools/env_layer_router.py, prefix /api/v1. Both endpoints require the search capability.

MethodPathDescription
GET/environmental-layersGrouped layer catalog (groups[].layers[] with id, label, levels, forecast_hours, element_type, units).
POST/environmental-layers/fetchFetch a layer and return a map_display card. Body: {element, bbox[4], config?, forecast_hour?, display_id?}.

Fetch behavior:

  • element_type == "tile_raster" short-circuits to a TileLayer spec (Iowa Mesonet NEXRAD, etc.), no fetch, no blob.
  • Otherwise auto-scales grid resolution by bbox span: >20° → 0.1° (~11 km), >5° → 0.05° (~5.5 km), else 0.02° (~2.2 km).
  • Field data is serialized to GeoParquet via write_vector_field, content-addressed into the blob store, and surfaced as a vector_field source pointing at /api/v1/geoparquet/<sha>.
  • Errors map to 422 (validation / empty coverage), 404 (unknown layer), 502 (upstream failure), 500 (serialization or blob write).
  • display_id provided ⇒ update_mode: "merge" so the card layers onto the existing map. Otherwise "full".

SSE conventions

Three endpoints stream Server-Sent Events: /assistant/chat, /assistant/peer-review, and /notifications/stream. Common shape:

  • Headers: Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no.
  • Frames are data: <json>\n\n. The notifications stream additionally sets id: (for Last-Event-ID reconnect) and event: notification.
  • The notification stream issues : keepalive\n\n every 5 s while idle. The chat stream relies on continuous event flow; if the client is_disconnected() mid-turn, the orchestrator’s cancellation check unwinds the loop.
  • Chat events carry a monotonic sequence field. Telemetry replay sorts by (timestamp, sequence).

The frontend SSE consumer (frontend/src/hooks/useDisplayEvents.ts) coalesces consecutive reasoning events and applies RAF-batched updates; see Streaming.

Error envelope

Every error response is {"detail": <string | array>}. FastAPI’s default 422 validator emits an array of field-level error objects; everything else is a string.

StatusCategoryCommon triggers
400Bad requestInvalid input that bypassed schema validation (malformed hash, wrong MIME, oversized payload via 413).
401UnauthenticatedMissing/invalid/expired JWT (when dev bypass is off).
403ForbiddenCapability missing, tenant mismatch, cross-user resource.
404Not foundResource missing or hidden by tenant scoping (cross-tenant reads are indistinguishable from absence).
409ConflictDuplicate corpus name, etc.
410GoneSpatial blob whose binding is tombstoned and unreachable.
413Payload too largeSnapshot uploads (>25 MB), settings.max_upload_bytes overruns.
422ValidationRequest body fails Pydantic schema; layer fetch has no coverage; bbox malformed.
500Server errorUnhandled exception. Goes through _unhandled_exception_handler which dedup-pushes a high-priority alert keyed on (path, exception class, args[:80]).
503Service unavailableDB re-initialized under a stale connection, or DI container not yet wired.

Tenant header

X-Tenant-ID: my-tenant

Every resource is strictly scoped by tenant. Omitting the header uses settings.default_tenant_id. Cross-tenant reads are indistinguishable from 404 by design, listing endpoints filter at the database level, and read endpoints 404 rather than 403 to avoid leaking the existence of out-of-scope resources where it matters (downloads, blobs).

Uniform Tool Contract, model-facing surface

Every tool handler’s return value is one of three named result variants, Ok | Hint | Err (engine/contract.py), applied at the dispatch boundary in engine/tool_registry.py:_apply_contract. The migration ratchet (tests/contract_deferred_baseline.json) reached zero deferred tools in June 2026; every tool registered in engine/tool_definitions.py:SYSTEM_TOOLS now goes through the contract. The contract governs the tool boundary the agent loop sees, not REST. REST endpoints are out of scope; calling /api/v1/predict/train directly still returns the legacy JSON envelope, that adapter (envelope_to_tool_result) stays in place for the orchestrator path even though the deferred set is empty.

What this means for REST callers in practice:

  • tool_result SSE events from /assistant/chat carry the rendered envelope text. Hint events look like recoverable corrections (“did you mean…”, “add filter:…”) rather than errors. The error event is reserved for actual platform failures.
  • The done event’s cost continues to come from the usage_log table. ToolSpec.is_cost_rollup=True on delegate/delegate_batch keeps leaf costs from being double-counted in the rollup.
  • Telemetry events carry a contract_envelope type (envelope-kind histogram per tool). It surfaces through /conversations/{id}/replay like any other event.

See Engine and Tool System for the boundary spec.

  • Engine, orchestrator, dispatcher, tool registry
  • Conversations, access control, telemetry replay
  • Fabric module, catalog, Kits, dream cycle
  • Admin module, model config, MCP, users
  • Knowledge module, corpora, ingest, RAG
  • Predict, training, scoring, SHAP
  • Insight, classification, extraction
  • Skills, SKILL.md authoring, discovery, and primitive execution
  • Streaming, SSE consumption on the client
  • Glossary, capabilities, roles, request context