Control Plane API v1 - contract shared by backend, portal, SDKs, CLI
Base path /v1. JSON only (except SSE/WebSocket where noted). OpenAPI at /openapi.json.
All SDKs, the portal and the CLI MUST use only the paths below. The backend MUST implement all of them.
Cross-cutting rules
- Auth (one of):
Authorization: Bearer <OIDC JWT>(Keycloak),X-API-Key: eai_<prefix>_<secret>(tenant API key), mTLS/SPIFFE (X-SPIFFE-IDset by the mesh sidecar, only whenEAI_AUTH_SPIFFE=1), or in devEAI_AUTH_DEV=1withX-Dev-Tenant: <tenant slug>andX-Dev-User: <id>(roles viaX-Dev-Roles: tenant_admin,developer). - Tenancy: the tenant is resolved from the credential, never from the body. Optional
X-Project: <slug>andX-Environment: dev|test|staging|prod. PostgreSQL row level security enforces isolation. - Roles:
platform_admin,tenant_admin,security_officer,finops,developer,auditor,agent,viewer. API keys carry scopes (models:invoke,agents:run,tools:invoke,rag:query,finops:read,admin:*...). - Errors:
{"error": {"code": "policy_denied", "message": "...", "message_i18n": {"en": "...", "fr": "..."}, "details": {...}, "request_id": "..."}}with HTTP 400/401/403/404/409/422/429/5xx. Language chosen byAccept-Language(en,fr); defaulten. Machine codes never change. - Pagination:
?limit=50&cursor=<opaque>->{"items": [...], "next_cursor": "..."|null}. - Idempotency:
Idempotency-Keyheader on POST that create runs, usage events, approvals. - Request surface:
docs/api/control-plane-v1.requests.json, generated from the app bypython -m eai.api_surface(services/control-plane), lists the query parameters and body fields each route reads (extra:ignore= undeclared keys are dropped,allow= free-form body). The SDK and portal conformance tests check every request they build against it;tests/test_api_request_surface.pyfails when it is stale. - Published OpenAPI:
docs/api/openapi-v1.json(sorted keys, noservers,info.x-contract-version), regenerated by the single commandpython -m eai.api_openapi(services/control-plane) after any route change;--checkandtests/test_api_openapi.pyfail when it is stale or when the change is breaking (removed route, removed 2xx response field, new required request field, narrowed request enum) without a major bump ofCONTRACT_VERSION. - Tracing: W3C
traceparentaccepted and propagated;X-Request-Idreturned. - Money: decimal strings with a currency.
- Audit: every mutation and every policy decision writes a hash-chained audit event.
- Security: every response carries security headers (nosniff, frame denial, strict CSP on JSON,
Cache-Control: no-storeon/v1, HSTS behind TLS); request bodies are limited (EAI_MAX_BODY_BYTES, uploadsEAI_MAX_UPLOAD_BYTES: 413payload_too_large); requests are rate limited per principal and per client IP (429rate_limitedwithRetry-After); calls to tenant-provided URLs go through the egress guard (403egress_denied,details.reason); prompts and untrusted content are screened for prompt injection (403prompt_injection_blockedwhen the tenant mode isblock). - No em dash / en dash anywhere in code, UI copy, docs.
Resource map (method path -> purpose)
System
- GET
/healthz, GET/readyz, GET/v1/meta(version, features, locales["en","fr"]) - GET
/metrics(Prometheus text format, scraped by the ServiceMonitor; not part of the OpenAPI schema) - GET
/v1/me(principal, tenant, roles, scopes) - GET
/v1/catalog/bricks, GET/v1/catalog/bricks/{id}(the A-H brick catalog). Each brick carriestechnology_enandtechnology_fr(localized technology);technologyis kept equal totechnology_enfor v1 backward compatibility. - GET
/v1/roadmap(12 phases) - GET
/v1/operations/{operation_id}(long operations, architecture B6.3): every long creation (agent runs, evaluation and red team runs, marketplace installations and upgrades, knowledge base reindex, connector sync, video generation) answers with the headersOperation-IdandOperation-Location; this route returns{operation_id, kind, state, status, http_status, resource {type, id, href}, accepted, result, created_by, created_at},resultbeing the live resource read through its own route with the caller's credentials. With anIdempotency-Keyheader a retry by the same principal with the same body replays the first answer (Idempotency-Replayed: true) without running the operation again; another body under the same key gets 409idempotency_conflict, a retry while the first request still runs gets 409operation_in_progress. Readable by its creator and bytenant_admin,auditor,security_officer,platform_adminof the same tenant (404 otherwise).
Contracts and manifests
- GET
/v1/contracts, GET/v1/contracts/{name}(JSON Schema), POST/v1/contracts/{name}/validatebody: document ->{valid, errors[]} - POST
/v1/manifests/applybody: any manifest (kindAgent|Model|Tool|Skill|Plugin|Connector|Channel) -> validate, policy check, upsert in the right registry, returns{kind, name, version, status} - GET
/v1/manifests/{kind}/{name}export as manifest
Identity and tenancy (brick A)
- GET/POST
/v1/tenants(platform_admin), GET/PATCH/v1/tenants/{id} - GET/POST
/v1/projects, GET/PATCH/DELETE/v1/projects/{id}; GET/POST/v1/projects/{id}/environments - Tenant hierarchy (A6.1, A6.2, ADR-0156): GET/POST
/v1/organizationsbody{slug, name, description?, metadata?}, PATCH/v1/organizations/{organization_id}body{name?, description?, status? (active | archived), metadata?, expected_version?}; GET/POST/v1/business-units(filtersorganization_id,parent_id) body{organization_id, parent_id?, slug, name, description?, metadata?}, PATCH/v1/business-units/{business_unit_id}(same body as an organization). tenant_admin writes, any tenant role reads. Project creation and update (/v1/projectsand/v1/projects/{id}) acceptorganization_id,business_unit_id(a unit alone brings its organization,nulldetaches) andmetadata; the project list filters onorganization_idandbusiness_unit_id. Every row carriesversion,created_by,updated_byandtrace_id(correlation id) stamped by PostgreSQL; PATCH withexpected_versionis optimistic: 409version_conflict{expected_version, current_version}when the row moved. Errors: 409conflict(duplicate slug), 422invalid_metadata(metadata must be a flat object of lower case keys with text, number, boolean or null values, 16 KB maximum, checked byeai.valid_metadatain the database), 422invalid_hierarchy(unit of another organization), 404not_found(organization or unit of another tenant). Audit eventsorganization.create,organization.update,business_unit.create,business_unit.update - GET/POST
/v1/members, PATCH/DELETE/v1/members/{id}(role assignment) - GET/POST
/v1/api-keys(secret shown once), DELETE/v1/api-keys/{id}, POST/v1/api-keys/{id}/rotate - GET/POST
/v1/service-identities(SPIFFE ids), DELETE/v1/service-identities/{id} - POST
/v1/service-identities/{identity_id}/assume(B9.3identity.assume_service){purpose, audience?, ttl_seconds? (30 to 3600, default 900), scopes? (default: the identity scopes)}: 201{token, context_id, kid, alg, issued_at, expires_at, audience, digest, context, service_identity: {id, name, spiffe_id}, effective: {roles, scopes, clearance}, assumed_by, on_behalf_of, decision_id}. A signed ExecutionContext whose subject isservice:<name>; it is not an API credential: downstream services verify it with POST/v1/context/verifyor the JWKS, and the caller revokes it with POST/v1/context/{context_id}/revoke. Needs the OpenFGA relationcan_assumeonservice_identity:<id>written explicitly (POST/v1/authz/tuples; no tenant role implies it, administrators included, and a wildcard subject such asuser:*is refused with 422authz_wildcard_not_allowed), then an allow decision forservice_identity.assume. The effective rights never exceed the caller's: the roles held by both (neverplatform_admin), the requested scopes the identity grants and the caller holds, the lower clearance. The caller stayson_behalf_ofin the signed document and is the actor of the audit eventsservice_identity.assume(allow or deny) andcontext.issue. Errors: 403relation_required,policy_denied,service_identity_scope_exceeds; 404not_found; 409service_identity_disabled; 400context_wrong_audience; 502authz_backend_error; 503context_authority_unavailable - GET/POST
/v1/identity/quota-profilesbody{name, period, enforcement, limit_cost, limit_tokens, currency}(tenant_admin writes; security_officer and auditor read), PATCH/v1/identity/quota-profiles/{id}body{period, enforcement, limit_cost, limit_tokens}(also updates the user quotas already granted from the profile), DELETE/v1/identity/quota-profiles/{id}(409 while a group mapping uses it) - GET/POST
/v1/identity/group-mappingsbody{group, project_id | project (slug), role, quota_profile_id | quota_profile (name)}, GET/PATCH/DELETE/v1/identity/group-mappings/{id}(PATCH acceptsclear_quota_profile; PATCH and DELETE revoke what the mapping granted). Applied at each OIDC authentication from thegroupsandentra_groupsclaims: membership and user quota granted, then revoked when the group leaves the token; memberships granted by hand are kept - POST
/v1/authz/checkbody{subject, relation, object, contextual_tuples?}(OpenFGA or builtin fallback;contextual_tupleshold for this check only and are never stored; an unreachable OpenFGA answers 502authz_backend_error, auditedauthz.checkdeny with reasonbackend_unavailable), GET/POST/DELETE/v1/authz/tuples(GET lists tuples, filterssubject,relation,object) - GET
/v1/identity/whoami(any authenticated role): the identity resolved for the request{id, kind, tenant_id, tenant_slug, roles, scopes, clearance, auth_method, display}(auth_method oidc, password, api_key, spiffe or dev) - POST
/v1/auth/login(no credential){username, password}: 200{token, token_type: Bearer, expires_at, must_change_password, user: {username, tenant_id, tenant_slug, roles}}. Opens a portal session of a local account (eai.local_accounts, scrypt hash); the tokeneais_<prefix>_<secret>is returned once, sent asAuthorization: Bearer, validEAI_LOCAL_SESSION_TTL_SECONDS. The first account comes fromEAI_BOOTSTRAP_ADMIN_USERNAMEandEAI_BOOTSTRAP_ADMIN_PASSWORDat API startup (roles platform_admin and tenant_admin on the tenantEAI_BOOTSTRAP_TENANT), created once and never reset; whilemust_change_passwordis true every other route answers 403password_change_required, except GET/v1/me, GET/v1/identity/whoami, POST/v1/auth/passwordand POST/v1/auth/logout. Errors: 401invalid_credentials(unknown login and wrong password alike),account_disabled; 429account_locked(afterEAI_LOCAL_AUTH_MAX_FAILURESwrong passwords, forEAI_LOCAL_AUTH_LOCKOUT_SECONDS,Retry-After); 403local_auth_disabled,tenant_suspended. Auditedauth.login(allow or deny) - POST
/v1/auth/password(portal session of a local account){current_password, new_password}: 200{changed, password_changed_at, revoked_sessions, must_change_password: false}; the other sessions of the account are revoked. Errors: 403invalid_current_password,password_session_required; 422password_too_weak{reasons: [too_short, too_long, same_as_username, unchanged, initial_or_known, letters_and_digits], min_length}(EAI_LOCAL_PASSWORD_MIN_LENGTH, default 12). Auditedauth.password.change - POST
/v1/auth/logout(portal session of a local account): 204, the token is refused from then on (401session_revoked). Auditedauth.logout - Resource ReBAC (
require_relation): projects, agents, tools, knowledge bases, MCP servers, skills and plugins check a relation (viewer to read, editor to change, owner to delete, can_invoke to run, invoke, query or proxy) with inheritance tenant, project, resource; a refusal is 403relation_required, auditedauthz.deny. Creating a resource writesuser:<id>owner and, withX-Project, theproject:<id>project link. - GET/POST
/v1/policies, GET/PUT/DELETE/v1/policies/{id}, POST/v1/policies/evaluate(Policy Contract request -> decision), GET/v1/policies/decisions - Policy history (IAM_-016): every create, update and delete appends an immutable revision. GET
/v1/policies/{id}/revisions(tenant_admin, security_officer, auditor, developer), GET/v1/policies/{id}/revisions/diff?from_revision=&to_revision=(field and rule level changes, rules keyed byid; defaults: previous against latest; 404 for an unknown policy) - POST
/v1/policies/simulatebody{candidate: <policy>, policy_id?, inputs: [Policy Contract requests], recorded: N}(tenant_admin, security_officer, developer): decisions of the active policies versus the same set with the candidate, per input and for the last N recorded decisions; returnsresults[{input, current, candidate, changed}]andsummary{total, changed, allow_to_deny, deny_to_allow}; nothing is changed, the run is auditedpolicy.simulate; only builtin candidates (422simulation_engine_unsupported) - Signed OPA bundles (IAM_-005): POST
/v1/policies/bundlesbody{revision?, data?: {"eai": {...}}}(tenant_admin, security_officer) packs the engine=opa policies (active and draft) with a.manifestrevision, signed RS256 withEAI_POLICY_BUNDLE_SIGNING_KEY_FILE(409 on an existing revision, 409policy_bundle_empty, 503policy_bundle_signing_unavailablein prod without key); GET/v1/policies/bundles(plus auditor) lists them; GET/v1/policies/bundles/{revision}/bundle.tar.gz(currentis the latest; ETag sha256) is the resource OPA polls with an API key; GET/v1/policies/engines(tenant_admin, security_officer, auditor) reports the revision each OPA bundle really runs (read from OPA/v1/data/system/bundles),expected_revision,in_sync, and the OpenFGA store and model in use - GET/POST
/v1/approvals, GET/v1/approvals/{id}, POST/v1/approvals/{id}/decide{decision: approve|reject, comment}(separation of duties: requester cannot approve) - POST
/v1/approvals/{id}/cancel{reason}(B9.3approval.cancel; the requester,tenant_adminorsecurity_officer): a pending approval endscancelled(decided_atset), auditedapproval.cancel(a refusal is audited too); a waiting run endsfailedwith the reasonapproval_cancelled, a pending break glass grant or mandate is rejected. Errors: 403forbidden, 404approval_not_found, 409approval_closed(not pending) - Promotions: POST
/v1/models/{model_id}/statuswith approved, PATCH/v1/tools/{name}with status approved, POST/v1/agents/{id}/resume, POST/v1/skills/{name}/status{status, reason}with approved, POST/v1/mcp/servers/{id}/status{status: active|disabled, reason}with active answer 202{approval_id, status: pending_approval, kind, ref, target_status}; on approval the risk gate (high risk needs an approved assessment) and the blocking promotion suites run, the outcome is inpayload.promotion{result: applied|blocked, reason, evaluations, failed_suites}. Retirements (restricted, deprecated, blocked, suspend, kill) stay immediate. - GET/POST
/v1/risk/assessments, GET/POST/v1/risk/controls, GET/POST/v1/risk/exceptions(expiry + approver), GET/v1/risk/evidence - GET/POST
/v1/incidents, GET/PATCH/v1/incidents/{id}, POST/v1/incidents/{id}/actions(suspend agent/model/provider, rotate key) - POST
/v1/incidents/alerts: Alertmanager receiver (webhook payload v4). Scopeincidents:alertsgranted explicitly (orincidents:*; the global wildcard*of an interactive session does not count) or rolealert_receiver. Body{status, alerts: [{status: firing|resolved, fingerprint, labels, annotations, startsAt, endsAt, generatorURL}]}. One incident (categoryalert) per open fingerprint:created,updated,resolvedorignored(resolved, never seen). Response{received, items: [{fingerprint, result, incident_id, tenant_id}]}. Audited (incident.alert.open|update|resolve) and forwarded to the SIEM. Alerts land in the tenant of the key; atenantlabel is honoured for platform administrators only. Errors: 401unauthenticated, 403forbidden, 403tenant_required, 400invalid_request(status other than firing or resolved), 422validation_error. - GET
/v1/governance/registry(agents, models, tools, owners, risk) - Secrets: POST
/v1/secrets{path, value}(write only), GET/v1/secrets(metadata only), DELETE/v1/secrets/{path}
Security (cross-cutting)
- GET
/v1/security/posture(platform_admin, security_officer, tenant_admin):environment,generated_at,protections(security headers, HSTS, allowed hosts, trusted proxies, body limits, rate limits, authentication methods and JWT algorithms, CORS origins, egress policy, API docs exposure),checks[{id, category, status: pass|warn|fail, severity: critical|high|medium|low, message_en, message_fr}],summary{pass, warn, fail}andtenant{tenant_id, settings}for a caller with a tenant. - GET/PUT
/v1/security/settings(tenant_admin, security_officer; PUT is a partial update audited assecurity.settings.update): tenant settings stored intenants.settings.security:prompt_injection_mode(off|detect|block, defaultEAI_PROMPT_INJECTION_DEFAULT_MODE),untrusted_content_wrapping(bool, default true),output_links(allow|strip_untrusted, defaultstrip_untrusted),output_link_allowlist(host names),egress_allowed_hosts(host names or*.suffix; when not empty, the tenant tools, connectors, MCP servers and providers may only call these hosts; IP addresses and private names are refused here). - Security events are audit events whose action starts with
security.(security.auth.failed,security.rate_limited,security.egress.denied,security.prompt_injection.detected,security.prompt_injection.blocked,security.output.link_stripped,security.settings.update), read through GET/v1/audit/events.
LLM management (brick B)
- GET/POST
/v1/providers, GET/PATCH/DELETE/v1/providers/{id}- Provider Registry (16+ providers: openai, azure_openai, azure_ai_foundry, anthropic, google_ai_studio, vertex_ai, aws_bedrock, mistral, cohere, deepseek, groq, together_ai, fireworks_ai, openrouter, ollama, vllm, plus qwen, zai, minimax, lm_studio, openai_codex, github_copilot, huggingface, custom, custom_anthropic). Credentials are write-only (credentialsstored in OpenBao/encrypted, never returned). - POST
/v1/providers/{id}/test(connectivity check), POST/v1/providers/{id}/disable(kill switch), POST/v1/providers/{id}/enable - GET
/v1/providers/catalog(preset list: id, label, default base URL, auth type, status configured/not configured/not connected, models hint) - GET/POST
/v1/models, GET/PATCH/DELETE/v1/models/{model_id}, POST/v1/models/{model_id}/status{status}(approved|restricted|deprecated|blocked), POST/v1/models/{model_id}/disable(kill switch), POST/v1/models/{model_id}/enable - Model sheet (fields beyond Model Manifest v1, stored in the spec, left out of the manifest export):
category(slug of a category visible to the tenant, 422category_unknown),display_name,knowledge_cutoff(YYYY-MM or YYYY-MM-DD, 422sheet_invalid);pricing{input_per_mtok, output_per_mtok, cached_input_per_mtok, cache_write_per_mtok, per_image, per_second, per_character, currency}as exact decimals, never negative (422pricing_invalid). GET/v1/models?category=filters by category; a model without a category gets one inferred from its modalities and capabilities. - POST
/v1/providers/{id}/models/discover(rolestenant_admin,platform_admin,ai_platform_engineer; auditedprovider.discover) - lists the models the configured provider serves from its own API with the stored credentials, through the egress guard and the TLS rule: OpenAI compatibleGET {api_base}/models(OpenRouter context and prices used), AnthropicGET /v1/models, GeminiGET /v1beta/models(x-goog-api-key), OllamaGET /api/tags, CohereGET /v1/models, Azure OpenAI deployments, AWS BedrockListFoundationModels. Answer{mode: api|manual_only, items, count, registered_count, reason?}; each item{provider_model_id, model_id (suggested), display_name, category (suggested), context_window, max_output_tokens, modalities, capabilities, pricing, reasoning_modes, registered, registered_model_id, hints_applied}, completed with the platform hints. Vertex AI, Azure AI Foundry, OpenAI Codex and GitHub Copilot answermanual_only(no list is invented). Errors: 409provider_disabled, 403egress_denied, 422tls_required/discovery_no_api_base, 502discovery_auth_failed/discovery_http_error/discovery_network_error/discovery_timeout/discovery_bad_response(withhostandupstream_status, never a credential). - POST
/v1/models/import{provider_id, models: [{provider_model_id, model_id?, category?, display_name?, context_window?, max_output_tokens?, modalities?, capabilities?, reasoning_modes?, default_effort?, knowledge_cutoff?, pricing?, status?}], scope?}(1 to 200 models, roles as POST/v1/models) - registers the selected discovered models, idempotent:{imported, skipped: [{model_id, provider_model_id, reason: already_registered}], imported_count, skipped_count}; one invalid model refuses the whole import (422import_invalidwithproblems). Auditedmodel.createper model andmodel.import. - Model categories: GET/POST
/v1/llm/categories, GET/PATCH/DELETE/v1/llm/categories/{slug}{slug, label: {en, fr}, description: {en, fr}, icon, sort_order}. Predefined platform categories (text, vision, multimodal, image, video, embedding, tts, stt, live) are read only (409category_builtin); a category used by a model cannot be deleted (409category_in_use); 409category_exists, 404category_not_found. Each item carriesbuiltin,scopeandmodel_count. Auditedmodel_category.create|update|delete. - POST
/v1/models/resolvebody: call parameters -> normalized/translated native parameters or explicit rejection (Capability Resolver) - Model versions (LLM_-004, roles: write
tenant_admin, readtenant_admin,security_officer,finops,developer,auditor,viewer): POST/v1/models/{model_id}/versions{version, licence_spdx | licence_terms_ref, weights: {source, digest: sha256:..., size_bytes}, capabilities}(422model_version_invalid, 409model_version_exists), GET/v1/models/{model_id}/versions, POST/v1/models/{model_id}/versions/{version}/approval(202, opens an approvalmodel_version_activationdecided bytenant_adminorsecurity_officerthrough POST/v1/approvals/{id}/decide, separation of duties) - Rollout and rollback (LLM_-016, role
tenant_admin): POST/v1/models/{model_id}/versions/{version}/rollout{canary_percent: 1..100, reason}(403model_version_not_approved, 409model_rollout_no_baseline; 100 publishes, less is a canary share served by the router, reported ineai.model_version), POST/v1/models/{model_id}/rollback{reason}(aborts the canary or restores the previous version, 409model_rollback_unavailable), GET/v1/models/{model_id}/rollout(active, canary, share and history; read roles) - Isolation (LLM_-015): PUT
/v1/llm/isolation{mode: connected|local_only|air_gapped, project_id, internal_hosts}(rolestenant_admin,security_officer), GET/v1/llm/isolation?project_id=(read roles). In an isolated mode the gateway refuses every external route with 403isolation_external_routebefore any outbound call and checks the outbound host again (403isolation_egress_denied). - GET/POST
/v1/routes, PUT/DELETE/v1/routes/{id}(Model Router rules and fallback chains), POST/v1/routes/simulate - GET
/v1/litellm/config(generated LiteLLM config, no secrets;model_infocarriesmax_input_tokens,max_output_tokens,input_cost_per_token,output_cost_per_token,cache_read_input_token_cost,cache_creation_input_token_cost, the unit costsoutput_cost_per_image,input_cost_per_second,output_cost_per_second,input_cost_per_characterby category,supports_*flags andeai_category), POST/v1/litellm/sync. vLLM, Ollama and LM Studio are LiteLLM providers like the others (endpoint and optional key): there is no deployment API. - LLM projects, quotas and LiteLLM keys (eai.domains.llm_management, roles
tenant_admin,platform_admin, always inside the tenant of the caller, 404 for another tenant): GET/v1/llm/management/status(configured,admin_url,master_keyconfigured|missing,reachable,error_code,missingsettings; never a secret value). GET/POST/v1/llm/projects, GET/PATCH/DELETE/v1/llm/projects/{project_id}{name, description, owner, project_id (tenant project id or slug), allowed_models, allowed_categories, status: active|suspended|archived, quota}(409llm_project_exists, 404tenant_project_not_found, DELETE 409llm_project_has_keys), PUT/v1/llm/projects/{project_id}/quota{budget_amount, budget_currency, budget_period: daily|weekly|monthly|total, token_limit, token_period, rpm_limit, tpm_limit, max_parallel_requests, alert_threshold_pct, hard_stop}(422invalid_quota), POST/v1/llm/projects/{project_id}/sync(push again). A project is mirrored into LiteLLM as a team (/team/new,/team/update: models, max_budget, budget_duration, tpm_limit, rpm_limit;hard_stop: falsesends no max_budget, alert only) and reportssync_statuspending|synced|failed withsync_error; its daily or monthly budget and token limit are FinOps quotas (eai.quotas, scopeproject, value the linked tenant project slug, namesllm-project:<id>:budget|tokens) enforced by the gateway. Keys: GET/POST/v1/llm/projects/{project_id}/keys{name, key_alias, models, max_budget, budget_period, rpm_limit, tpm_limit, max_parallel_requests, duration_days}(LiteLLM/key/generatewith the project team; 422llm_key_limit_exceeds_projectorllm_key_model_not_allowedwhen a limit is wider than the project; 201 returns the clearkeyonce withCache-Control: no-store, only the LiteLLM token hash, alias and prefix are stored; the list refreshesspendfrom/key/info,usage_sourcelitellm|stored), PATCH/DELETE/v1/llm/projects/{project_id}/keys/{key_id}(/key/update; DELETE revokes through/key/deleteand keeps the row as revoked), POST/v1/llm/projects/{project_id}/keys/{key_id}/block, POST/v1/llm/projects/{project_id}/keys/{key_id}/unblock(409llm_key_revoked). Without LiteLLM: 503litellm_not_configured(setEAI_LITELLM_ADMIN_URLorEAI_LITELLM_URL, andEAI_LITELLM_MASTER_KEY_REFopenbao:<path>#<field>|env://<VARIABLE>orEAI_LITELLM_MASTER_KEY); LiteLLM down: 502litellm_unreachable; LiteLLM refusal: 502litellm_rejected. Every mutation is audited (llm_project.*,llm_key.*), never with the key. - GET
/v1/adapters(specialized adapters for video generation, realtime voice, batch...) - POST
/v1/context/planbody: window, output, reasoning, safety, segments -> budget and compression plan (Context Manager) - Gateway (the only path agents use to call models; OpenAI-compatible bodies plus
eaiextension):- POST
/v1/gateway/chat/completions(stream via SSE whenstream=true) - POST
/v1/gateway/embeddings(optionaldimensions, one of the sizes the registry declares for the model; 422capability_not_supportedwhen the model has noembeddingscapability, 422embedding_dimensions_invalid) - POST
/v1/gateway/rerank(422capability_not_supportedwhen the model has norerankcapability; a pure embedding or rerank model is refused the same way on chat completions) - POST
/v1/gateway/images/generations, POST/v1/gateway/audio/transcriptions(multipartfile+ fields, or JSONcontent_base64), POST/v1/gateway/audio/speech, POST/v1/gateway/video/generations(+ GET/v1/gateway/video/generations/{id}) - POST
/v1/gateway/estimate(estimated cost before execution) - Extension body field
eai:{effort, agent_id, session_id, run_id, task_id, data_classification, purpose, region_allow, fallback_policy}; response headerX-EAI-Decision-Id,X-EAI-Cost-Estimated,X-EAI-Cost-Measured(listed in CORSAccess-Control-Expose-Headersso browsers can read them).
- POST
Agents and execution (brick C)
- GET/POST
/v1/agents, GET/PATCH/DELETE/v1/agents/{id}, GET/v1/agents/{id}/versions, POST/v1/agents/{id}/suspend, POST/v1/agents/{id}/resume, POST/v1/agents/{id}/kill - POST
/v1/agents/{id}/runs, GET/v1/runs, GET/v1/runs/{id}(Execution Contract), POST/v1/runs/{id}/cancel, POST/v1/runs/{id}/resume, GET/v1/runs/{id}/steps, POST/v1/runs/{id}/steps, GET/v1/runs/{id}/replay, GET/v1/runs/{id}/events(SSE) - POST
/v1/runs/{id}/checkpoints, GET/v1/runs/{id}/checkpoints, GET/v1/checkpoints/{id} - POST
/v1/runs/{id}/heartbeat(response shape below: the runtime MUST stop whenactionisstop), GET/v1/heartbeats(stale runs;?format=prometheusreturns Prometheus text metrics) - POST
/v1/runs/{id}/subagents(spawn, limited by depth and count), GET/v1/runs/{id}/subagents - POST
/v1/a2a/messages, GET/v1/a2a/inbox(agent to agent, versioned envelope) - GET/POST
/v1/sessions, GET/DELETE/v1/sessions/{id}, POST/v1/sessions/{id}/messages, GET/v1/sessions/{id}/messages - GET/POST
/v1/schedules, PATCH/DELETE/v1/schedules/{id}(cron, Temporal-backed) - GET/POST
/v1/triggers, DELETE/v1/triggers/{id}, POST/v1/events/publish(NATS subject) - POST
/v1/sandbox/executions, GET/v1/sandbox/executions/{id}(gVisor/Kata profiles, resource limits)
Tools, MCP, skills, plugins, connectors, channels (brick D)
- Tools: GET/POST
/v1/tools, GET/PATCH/DELETE/v1/tools/{name}, POST/v1/tools/{name}/invoke(Tool Gateway: scopes, policy, DLP, approval, cost), GET/v1/tools/invocations - MCP: GET/POST
/v1/mcp/servers, DELETE/v1/mcp/servers/{id}, GET/v1/mcp/servers/{id}/tools, POST/v1/mcp/gateway/{server_id}(JSON-RPC 2.0 proxy: initialize, tools/list, tools/call, resources/list, resources/read, prompts/list). GET?project=<id or slug>keeps the servers of one project; POST bodyproject(id or slug, default theX-Projectheader) attaches the server to that project (columnproject_id, OpenFGA project tuple);auth.typeisnone,bearer,api_key(header),query_param(param, the secret query parameter is added at call time and never stored in the URL) orconnector. Isolation per project: a server attached to a project is listed and reached only from that project (theX-Projectheader or an API key bound to the project); from another project the gateway answers a JSON-RPC errormcp_server_project_mismatch, and the routes/v1/mcp/servers/{id}/tools,/v1/mcp/servers/{server_id}/discover,/v1/mcp/servers/{id}/status,/v1/mcp/catalog?server=and the deletion of the server answer 403mcp_server_project_mismatch(audited); a request made in a project sees the servers of its project plus the servers shared by the tenant (no project),?project=of another project is 403mcp_project_forbidden; tenant_admin and security_officer without a project context keep the view of every project. MCP servers are internal only, never online: a public host is refused at registration and at every call with 403mcp_online_forbidden(auditedsecurity.egress.denied, also for a declared name that resolves to a public address); the gateway reaches the servers hosted by the Navin Manager, the MCP Runtime instances and the internal hosts of EAI_MCP_INTERNAL_HOSTS - MCP servers hosted by the Navin Manager (ADR-0165): POST
/v1/mcp/hosted/sync(?project=id or slug, default the request project; tenant_admin) reads the list of the hosted servers of the tenant from the Navin Manager (service mcp-host, Manager route /api/v1/mcp/hosted, permission mcp:publish), keeps each token in the Credential Broker (openbao://mcp/hosted/<id>), creates or updates the server<project>-<name>of the project (bearer authentication, allowed tools,hosted_ref) and disables the servers no longer hosted; answer{created, updated, disabled, skipped: [{id, name, project, reason: other_tenant|project_not_found|not_active|invalid_url|token_missing|project_moved}]}, never a token; the mcp-host address must be named inEAI_EGRESS_PRIVATE_ALLOWLIST - MCP catalogue: POST
/v1/mcp/servers/{server_id}/discover(tenant_admin, developer, relation editor; protocol discovery initialize + tools/list, new or changed schema = new version with approvalmcp_tool_version), GET/v1/mcp/catalog?server=(any tenant member; tools, input and output schemas, versions, scopes, status pending_approval, approved, rejected) - MCP Gateway access per user and agent: PUT
/v1/mcp/servers/{server_id}/access{principals: [principal ids]}(tenant_admin, developer, application_owner; empty list: every member of the project of the server) sets who may call the server; a caller not listed gets the JSON-RPC errormcp_principal_not_allowedfrom the gateway and 403mcp_principal_not_allowedfrom/v1/mcp/servers/{id}/toolsand/v1/mcp/servers/{server_id}/discover(audited), and the listings hide the servers the caller may not use; tenant_admin and security_officer keep the management of every server but never call a server they are not listed on; the server of an MCP Runtime instance follows its instance (its owner and its agents only, 409mcp_instance_access_managed) - MCP Runtime (ADR-0167, ADR-0170, ToolHive Kubernetes Operator,
EAI_MCP_RUNTIME_ENABLED): isolated MCP instances per tenant, project and user. GET/v1/projects/{project_id}/mcp/instances?status=(relation viewer;{enabled, policy, catalog, templates, items}: the templates of the MCP catalogue of the company in SeaweedFS (EAI_MCP_CATALOG_STORAGE_PROFILE,catalog: source seaweedfs or builtin, profile, key, version, stale, error; the last valid version keeps serving when SeaweedFS cannot be read, 503mcp_catalog_unavailablewhen none was ever read, 503mcp_catalog_not_configuredin production without a profile) with their typed parameters, the own instances of the caller, every instance for tenant_admin and security_officer), POST/v1/projects/{project_id}/mcp/instances{template, name?, params, credential_ref?, agents?, allowed_tools?, idle_timeout_seconds?}(relation editor, developer or tenant_admin; 202status: provisioning; the project policy decides the templates, the hosts (*.domainpatterns), the database roles and the quotas: 403mcp_template_not_allowed,mcp_host_not_allowed,mcp_database_role_not_allowed, 409mcp_instance_quota,mcp_instance_exists, 422mcp_param_missing,mcp_param_invalid,mcp_param_unknown,mcp_credential_required, 409mcp_runtime_disabled; credentials of the instance only: dynamic OpenBao database lease forpostgres, the personalopenbao://orenv://secret of the user forgithub; written in a Secret of the instance, never returned), GET/v1/projects/{project_id}/mcp/instances/{instance_id}(owner or tenant administrators, else 404mcp_instance_not_found; state read again from ToolHive:provisioning,readywithurlandserver_id(gateway entry callable by its owner and its agents only, JWT of audienceurn:eai:mcp-instance:<id>),failed(failure_reason, refused by the gateway until POST .../start),sleeping(sleep_reasonidle, failure, user, removed, lifetime: recreated by the next call of its owner or one of its agents, newgeneration),terminating,terminated;events: lifecycle journal created, credentials_issued, provisioned, ready, sleep_requested, slept, wake_requested, start_requested, woken, failure_detected, health_failed, destroy_requested, credentials_revoked, resources_deleted, terminated, each with itsgenerationand thetrace_idof the request), DELETE/v1/projects/{project_id}/mcp/instances/{instance_id}(end of the task: lease revoked, MCPServer, MCPToolConfig, CiliumNetworkPolicy and Secret deleted, gateway entry disabled, journal kept;termination_reasonuser or administrator), POST/v1/projects/{project_id}/mcp/instances/{instance_id}/stop(owner or tenant administrators: the instance sleeps now, lease revoked and resources deleted, gateway entry kept), POST/v1/projects/{project_id}/mcp/instances/{instance_id}/start(202: a sleeping or failed instance starts again now, failure count reset, template and policy checked again; 409mcp_instance_busywhile a cleanup is pending), PUT/v1/projects/{project_id}/mcp/policy{allowed_templates, allowed_hosts, allowed_database_roles, idle_timeout_seconds (default 900), max_lifetime_seconds (default 28800), max_instances_per_user, max_instances_per_project}(tenant_admin), GET/v1/mcp/runtime/jwks(no authentication: public keys read by the ToolHive proxies to check the tokens of the gateway). The lifecycle controller runs in the API pods, the only pods allowed to call the Kubernetes API, one replica at a time (EAI_MCP_RUNTIME_REAPER_SECONDS): instances idle past their idle time or at the end of the lifetime of their generation sleep; an instance that does not work (MCPServer Failed, a*Validatedcondition False, pods in CrashLoopBackOff or failing to pull their image, never picked up by the operator, provisioning timeout, two failed health probes of the proxy, proxy not answering a call) is destroyed at once and sleeps, failed after EAI_MCP_RUNTIME_FAILURE_LIMIT failures within EAI_MCP_RUNTIME_FAILURE_WINDOW_SECONDS; a call through the gateway recreates a sleeping instance and waits for it (EAI_MCP_RUNTIME_WAKE_TIMEOUT_SECONDS), the JSON-RPC error data codesmcp_instance_failed,mcp_instance_starting,mcp_instance_busyandmcp_instance_unavailabletell why a call is refused; failed revocations and deletions are retried; the journaleai.mcp_runtime_eventsis append only and outlives the instance - Skills: GET/POST
/v1/skills, GET/v1/skills/{name}, POST/v1/skills/{name}/verify(signature), GET/v1/skills/{name}/load(Skill Loader: refuses unsigned or blocked) - Plugins: GET/POST
/v1/plugins, GET/v1/plugins/{name}, POST/v1/plugins/{name}/install, POST/v1/plugins/{name}/uninstall, GET/v1/plugins/{name}/status - Connectors: GET/POST
/v1/connectors, GET/PATCH/DELETE/v1/connectors/{name}, POST/v1/connectors/{name}/credentials(write only, Credential Broker), POST/v1/connectors/{name}/test, POST/v1/connectors/{name}/sync, GET/v1/connectors/{name}/sync-runs, POST/v1/credentials/broker/lease(short-lived delegated token, agent never sees the long-lived secret; optionalon_behalf_of: the broker checks the user rightcan_invokeonconnector:<name>before any outbound call and auditsconnector.on_behalf_ofwith user, on_behalf_of, connector and decision) - Webhooks: POST
/v1/webhooks/{connector}(headersX-EAI-Tenant: <tenant slug>,X-EAI-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, optionalX-EAI-Delivery: <id>for dedupe; 5 minute replay window; 401invalid_signatureorwebhook_replay, 409webhook_duplicate), GET/v1/webhooks/deliveries - Channels: GET/POST
/v1/channels, GET/PATCH/DELETE/v1/channels/{name}, POST/v1/channels/{name}/inbound(message in -> run), GET/v1/channels/{name}/sessions. GET?project=<id or slug>keeps the channels of one project; POST?project=attaches the channel to a project (409channel_project_conflictfor a channel of another project). Types: web, mobile, email, slack, teams, whatsapp, sms, voice, api and the Navin channels discord, telegram, matrix, mattermost, signal;spec.connection{provider: navin|custom, adapter, settings, secret_refs}names the channel adapter run by the channel bridge of the SDK - Navin library (ADR-0164): GET
/v1/navin-library(version of the vendored navin package, counts, rule sources), GET/v1/navin-library/{kind}(kind skills, plugins, commands, agents, mcp-presets or channels;q,category), GET/v1/navin-library/{kind}/{name}(content: SKILL.md and files, plugin.json and README, Markdown, full preset or channel contract); any tenant member. Per project (relation viewer to read, editor and tenant_admin, developer or application_owner to write): GET/v1/projects/{project_id}/navin-library(activations, MCP servers and channels of the project), POST/v1/projects/{project_id}/navin-library/activations{kind: skill|plugin|command|agent, name}(a skill is registered in the Skill Registry with the library signaturenavin-library:<name>, verified against the library digest; a plugin brings its skills, commands and agents), DELETE/v1/projects/{project_id}/navin-library/activations/{activation_id}(a plugin removes what it brought; a library skill no project uses becomes deprecated), POST/v1/projects/{project_id}/navin-library/mcp-presets/{name}/install{name?, url?, transport?, values, credential_refs, allowed_tools?}(MCP server of the project from a preset; secret fields only asopenbao://orenv://references; 422mcp_preset_field_missing,mcp_preset_secret_ref_required, 409mcp_preset_needs_endpointfor a stdio preset withouturl; the list gives thehostingof each preset, command, local or online, and an online preset is installed only withurlset to the internal address where the company hosts it, else 403mcp_online_forbidden), POST/v1/projects/{project_id}/navin-library/channels/{name}/install{name?, agent_ref?, settings, secret_refs, auth?, limits?, dlp?}(channel of the project from the setup contract of a Navin channel; 422channel_field_missing,channel_field_invalid). Audit eventsnavin_library.activate,navin_library.deactivate,navin_library.mcp_install,navin_library.channel_install - Project rules: GET
/v1/projects/{project_id}/rules, GET/v1/projects/{project_id}/rules/prompt({prompt, always, conditional, digest}: always on rules inlined, conditional rules listed with their globs), PUT/v1/projects/{project_id}/rules/{name}{content, mode: always|conditional, description, globs, source}(201 created, 200 replaced; README is reserved, 64 KB per rule), DELETE/v1/projects/{project_id}/rules/{name}, POST/v1/projects/{project_id}/rules/import{files: [{path, content}]}(reads.navin/rules/*.md,.cursor/rules/*.mdcwith alwaysApply, description and globs,.cursorrules,.clinerules,.windsurfrules,.github/copilot-instructions.md; other files are returned inskipped). Audit eventsproject.rule.put,project.rule.delete
Data, RAG, memory (brick E)
- POST
/v1/privacy/classify{text|media_id}-> classification + findings - POST
/v1/dlp/inspect, POST/v1/dlp/redact(mode: detect|mask|tokenize|block), GET/POST/v1/dlp/rules - POST
/v1/tokenization/tokenize, POST/v1/tokenization/detokenize(policy + audit) - POST
/v1/encryption/encrypt, POST/v1/encryption/decrypt, GET/POST/v1/encryption/keys, POST/v1/encryption/keys/{id}/rotate - Knowledge: GET/POST
/v1/knowledge-bases(POST body addsembedding_model, a registry model with theembeddingscapability, defaultEAI_RAG_EMBEDDING_MODEL, anddimensions, one of the sizes the model declares; 422embedding_model_invalidorembedding_dimensions_invalid), GET/DELETE/v1/knowledge-bases/{id}(GET returnsvector_generation, the runningreindexjob anddatasets, one per source of the knowledge base, architecture A6.1-12:{id, kb_id, name, source_kind: upload|connector, source_ref, classification (the highest received, never lowered automatically), owner, documents (those the caller may read), last_ingested_at, version, lineage_node: {namespace, name}}), POST/v1/knowledge-bases/{id}/documents(ingest into the dataset of its source, returned asdataset_idand cited by the lineage events, then chunk, embed through the Model Gateway with the document classification: 403classification_exceededwhen the embedding model ceiling is lower, 409knowledge_base_reindexingduring a reindex, 502embedding_dimension_mismatch, 503vector_store_unavailable), GET/v1/knowledge-bases/{id}/documents, DELETE/v1/knowledge-bases/{id}/documents/{doc_id}, POST/v1/knowledge-bases/{id}/query(ACL filtered retrieval; body{query, top_k, min_score, mode: vector|hybrid, rerank, rerank_model}, response addsmode,reranked,warningsandembedding_model), PUT/v1/knowledge-bases/{id}/acl, POST/v1/knowledge-bases/{id}/reindex(owner relation; body{embedding_model, dimensions}, 202 with the job{job_id, status, from, to, documents_total, ...}, run by the worker jobembeddings_reindex; 409knowledge_base_reindexing, 422embedding_model_invalid), GET/v1/knowledge-bases/{id}/reindex-jobs(last 50 jobs). Governed RAG (kit sections 7 and 10): POST documents acceptssource_connector(the connector must exist and be active, else 403source_permission_denied; checked again at query time) andchunk_readers(per chunk readers from the source permissions, by chunk position, filtered in the vector store and again before the context is built); a document flagged by the poisoning screening (prompt injection, hidden text, abnormal instructions, outlier embeddings) is stored withstatus: quarantinedand never retrieved; restricted and secret chunk text is stored as a Tink envelope ciphertext; the query response addscitations(document_id,document_version,source_uri,chunk_position) of authorized sources only andlineage_run_id(OpenLineage events, GET/v1/lineage/graph). GET/v1/knowledge-bases/{id}/quarantine(tenant_admin or security_officer, data steward duty) lists the quarantined documents; POST/v1/knowledge-bases/{id}/documents/{doc_id}/review(tenant_admin or security_officer; body{decision: release|reject, reason}; 409document_not_quarantined) releases or rejects one, auditedknowledge.document.releaseorknowledge.document.reject. Knowledge extensions (V206, ADR-0175 decision 2): POST/v1/knowledge-basesacceptschunking: words|structureandretrieval(strategy:mode,fusion: rrf|weighted,rrf_k,candidates,rerank,rerank_model,rerank_candidates,dedupe,dedupe_threshold,context_window,token_budget,conflicts), replaced with PUT/v1/knowledge-bases/{id}/retrieval(owner relation, auditedknowledge_base.retrieval.update); POST documents acceptscollectionandlanguage; a document with theuriof an existing one becomes its next version, the history is read with GET/v1/knowledge-bases/{id}/documents/{doc_id}/versions(statescurrent|superseded|deleted, Docling artifacts kept in the object storage with kind, object key, size and sha256); GET/v1/knowledge-bases/{id}/documents/{doc_id}/chunkslists the chunks with their structural metadata (source_page,section_title,element_type,language,char_start,char_end,bbox,document_version,content_hash,embedding_model,token_count,structure); the query body addsmode: vector|dense|lexical|hybrid(PostgreSQL full text per chunk language, reciprocal rank fusion), the strategy options andfilters{collections, document_ids, metadata, element_types, languages}applied in the store with the ACL,diagnostics: true; the response addsstrategy,score_kind, per resultrank,scores,metadataandcontext,signals.possible_conflicts(a heuristic signal, never a verdict),no_result,context_budget,query_languageanddiagnostics_id, read back with GET/v1/knowledge-bases/{id}/query-diagnostics. Asynchronous ingestion (V207): POST/v1/knowledge-bases/{kb_id}/documents/uploads(editor relation; body{title, filename, content_base64, mime_type, uri, classification, readers, metadata, collection, language}, headerIdempotency-Keyreplays the same answer, 409idempotency_conflictwith another body; the size, the declared format against the real signature and the file name extension are checked, 413payload_too_large, 422unsupported_format,format_mismatch,extension_mismatchorarchive_refused, 409ingestion_storage_required; the bytes go to the quarantine prefix; 202{job_id, kb_id, document_id, document_key, version, state: uploaded, detected_format, size_bytes, sha256}), run by the worker jobknowledge_ingestion(statesuploaded,quarantined,scanning,rejected,extracting,normalizing,chunking,embedding,indexing,enriching_memory,ready,partially_ready,failed,reindexing,deleting,deleted; scanning through the file security domain, fail closed), GET/v1/knowledge-bases/{kb_id}/ingestion-jobs/{job_id}(the job with itshistory), POST/v1/knowledge-bases/{kb_id}/ingestion-jobs/{job_id}/retry(failed jobs only, else 409ingestion_job_not_failed), GET/v1/knowledge-bases/{kb_id}/documents/{doc_id}/status(version state,document_status,searchableand the last ingestion job) - Memory: POST
/v1/memory, GET/v1/memory, GET/DELETE/v1/memory/{id}, POST/v1/memory/search - Memory engines (Navin Memory Provider, ADR-0174): GET/POST
/v1/memory/engines, GET/v1/memory/engines/{engine_id}/capabilities, GET/v1/memory/engines/{engine_id}/health, DELETE/v1/memory/engines/{engine_id}, PUT/DELETE/v1/memory/engines/{engine_id}/credential - Agent memory policy: GET/PUT
/v1/memory/agents/{agent_id}/policy, GET/v1/memory/agents/{agent_id}/policy/versions, POST/v1/memory/agents/{agent_id}/policy/restore - Agent memories: GET/POST
/v1/memory/agents/{agent_id}/memories, POST/v1/memory/agents/{agent_id}/recall, POST/v1/memory/agents/{agent_id}/memories/search, POST/v1/memory/agents/{agent_id}/memories/purge, POST/v1/memory/agents/{agent_id}/memories/export, GET/PATCH/DELETE/v1/memory/agents/{agent_id}/memories/{item_id} - Agent knowledge graph (Cognee): POST
/v1/memory/agents/{agent_id}/knowledge, GET/v1/memory/agents/{agent_id}/knowledge/datasets, POST/v1/memory/agents/{agent_id}/knowledge/graph, POST/v1/memory/agents/{agent_id}/knowledge/search, GET/v1/memory/agents/{agent_id}/knowledge/datasets/{dataset_id}/relations, DELETE/v1/memory/agents/{agent_id}/knowledge/datasets/{dataset_id} - Retention: GET/POST
/v1/retention/policies, POST/v1/retention/run, POST/v1/retention/erase(subject erasure, Opik traces and ClickHouse rows included underexternal), POST/v1/retention/legal-hold - Lineage: POST
/v1/lineage/events(OpenLineage), GET/v1/lineage/graph, GET/v1/lineage/datasets/{id} - Prompts: GET/POST
/v1/prompts, GET/v1/prompts/{name}/versions, POST/v1/prompts/{name}/render
Multimodal (brick F)
- GET/POST
/v1/media(GET lists the tenant media with pagination; POST uploads, scans, classifies and stores encrypted), GET/v1/media/{id}(?include_content=truereturns the decrypted content to authorized callers), DELETE/v1/media/{id} - POST
/v1/multimodal/vision, POST/v1/multimodal/images/generate, POST/v1/multimodal/images/edit, POST/v1/multimodal/video/process(frames, audio extraction), POST/v1/multimodal/video/generate, POST/v1/multimodal/stt, POST/v1/multimodal/diarize, POST/v1/multimodal/tts, POST/v1/multimodal/dlp - GET
/v1/multimodal/jobs, GET/v1/multimodal/jobs/{id}, POST/v1/multimodal/jobs/{id}/complete(GPU workers report the result and the measured units of a queued job) - Realtime: POST
/v1/realtime/sessions(returns LiveKit token + ws url + session id), WebSocket/v1/realtime/ws/{session_id}(events:session.update,input_audio.append,transcript.delta,response.audio.delta,usage.update,error), GET/v1/realtime/sessions/{id}, POST/v1/realtime/sessions/{id}/end, POST/v1/realtime/sessions/{id}/token(new single usews_token, expiryws_token_expires_at, 409session_ended). Thews_tokenis short lived (EAI_REALTIME_WS_TOKEN_TTL_SECONDS, 300 s), single use and bound to the tenant, the session and the identity; it is checked before any database read. Limits: message size (EAI_REALTIME_WS_MAX_MESSAGE_BYTES, close 1009), event rate (EAI_REALTIME_WS_MAX_EVENTS_PER_SECOND,EAI_REALTIME_WS_BURST, close 4429), session duration and idle timeout (EAI_REALTIME_WS_MAX_SESSION_SECONDS,EAI_REALTIME_WS_IDLE_TIMEOUT_SECONDS, close 4408); invalid, expired or replayed token: close 4401. - Realtime voice governance (consent, identity, turns, transcripts; guide
docs/operations/en/voice-runtime.md): POST/v1/realtime/sessionsalso acceptsagent_idandon_behalf_of(an agent principal acts for that user; a user may only name itself, otherwise 403identity_mismatch) and refusesconfig.dlp_modedetect(422parameter_invalid). POST/v1/realtime/sessions/{id}/consents(roles tenant_admin, developer, agent; bodyparticipant_identity?,purpose,recording_allowed,transcription_allowed,retention_days1 to 3650,methodverbal, click, written or api; 201, auditedrealtime.consent.record), GET/v1/realtime/sessions/{id}/consents, POST/v1/realtime/sessions/{id}/consents/{consent_id}/revoke(roles tenant_admin, developer, agent; auditedrealtime.consent.revoke), GET/v1/realtime/sessions/{id}/turns(participant, user, agent, trace id, status, latency), GET/v1/realtime/sessions/{id}/transcripts(roles tenant_admin, developer, auditor, security_officer; DLP filtered text only). WebSocket events added: clientresponse.create {text, voice?},response.cancel,input_audio.speech_started(barge-in, also anyinput_audio.appendwhile a response streams); serverresponse.created,response.audio.delta {turn_id, sample_rate},response.done {latency_ms},response.cancelled {reason, chunks_sent},recording.saved(afterinput_audio.commitwithconfig.record),transcript.delta {turn_id, trace_id, latency_ms}, errorsconsent_required {purpose}(transcription or recording without an active consent, auditedrealtime.turn.refuseddeny),transcript_blocked,tts_failed,response_in_progress,no_active_response. Retention: POST/v1/retention/runresource typerealtimedeletes transcripts and recordings past the consent retention.
Quality and observability (brick G)
- Evaluations: GET/POST
/v1/evaluations/suites, GET/v1/evaluations/suites/{id}, POST/v1/evaluations/runs, GET/v1/evaluations/runs, GET/v1/evaluations/runs/{id}, POST/v1/evaluations/gates/check(blocking gate before promotion) - A/B comparisons (LLM_-013): POST
/v1/evaluations/comparisons{suite_id, baseline: {type, ref, version}, candidate: {...}, tolerance, outputs: {baseline, candidate}}(rolestenant_admin,developer,security_officer; 201 with both runs, per metric deltas and the verdictcandidate_better|equivalent|regression|inconclusive; 422comparison_invalid), GET/v1/evaluations/comparisons?suite_id=, GET/v1/evaluations/comparisons/{id}(alsoauditor,viewer,finops; 404comparison_not_found) - Red team: POST
/v1/redteam/runs, GET/v1/redteam/runs, GET/v1/redteam/runs/{id} - Audit: GET
/v1/audit/events(filters actor, action, resource, from, to), GET/v1/audit/events/{id}, GET/v1/audit/verify(hash chain), POST/v1/audit/export(WORM, signed with OpenBao Transit or HMAC in dev, see docs/operations/en/siem-audit.md), GET/v1/audit/correlations(events grouped by trace_id/run_id) - Application audit events (B9.3
audit.record): POST/v1/audit/events(tenant_admin,developer,agent,security_officer,application_owner,ai_platform_engineer){action: app.<name>, resource: {type, id, name?}, outcome? (success | failure | error), details? (at most 16384 bytes of JSON), classification?, trace_id? (32 hex), event_time? (client clock, at most EAI_AUDIT_CLOCK_SKEW_SECONDS ahead)}: 201{event_id, sequence, hash, prev_hash, occurred_at, event_time, action, stream: sdk, actor, decision}. The server appends the event to the tenant hash chain, sealed and exported (outbox, SIEM, WORM) like the platform events: the actor comes from the authentication,occurred_atand the sequence from the server, the client details stay underevidence.details.attributesnext tostream: sdk, credentials are redacted. The action must be in theapp.namespace and no other field is accepted (422), so a client never writes a platform event. Decided by policy first (audit.record, 403policy_denied). Errors: 403forbidden(read only roles), 422audit_event_too_large,audit_event_time_invalid - Telemetry: POST
/v1/telemetry/spans(ingest from SDK, forwarded to Opik, the OTel collector and ClickHouse), GET/v1/telemetry/overview, GET/v1/telemetry/traces/{trace_id}, GET/v1/telemetry/slo?window_minutes=60&tenant_id=(roles platform_admin, tenant_admin, auditor, viewer; a tenant caller sees its tenant only,slo_scope_forbiddenotherwise; per service: requests, errors, over_latency_target, availability and latency objectives with budget_remaining and burn_rate;slo_window_invalid422 outside 1 to EAI_SLO_RETENTION_MINUTES; auditedtelemetry.slo.read; each service also carriestarget_source: database | settings), PUT/v1/telemetry/slo/targets/{service}(platform_adminonly; body{availability (0.5 to < 1), latency_seconds (> 0 to 300), latency_objective (0.5 to < 1), reason?}; writes the objectives ofcontrol_plane_api,model_gateway,tool_gatewayorrealtimeineai.service_slos, which GET/v1/telemetry/sloreads before the EAI_SLO_* defaults; 200{service, availability, latency_seconds, latency_objective, reason, version, updated_by, updated_at, source: database}; 403forbidden, 404slo_service_unknown, 422 validation; auditedtelemetry.slo.target.update). The span ingestion keeps in PostgreSQL a bounded summary only (allow listed attributes, strings cut to 128 characters, at most 2048 bytes,input_tokens,output_tokens,attributes_dropped); the full span goes to the OTel collector (ADR-0157) - Live observability (A4.4-04, FRON-015; roles of the SLO view: platform_admin, tenant_admin, auditor, viewer, support_engineer; tenant rows only): GET
/v1/telemetry/traces?status=ok|error|unset&service=&run_id=&from=&to=&limit=(1 to 500, default window the last 24 h):{items: [{trace_id, name, service, start_time, duration_ms, status, spans, errors, run_id, session_id}], from, to}, one row per root span, newest first; GET/v1/telemetry/logs?level=error|info&service=&trace_id=&q=&from=&to=&limit=(1 to 1000): request journal built from the bounded span summaries{items: [{at, level, service, name, message, duration_ms, trace_id, span_id, run_id, attributes}], from, to}(only the allow listed attributes, never a prompt or an output); GET/v1/telemetry/stream?since=&interval=&timeout=&max_events=(text/event-stream): Server-Sent Eventstrace(new root span),incident(incident created or updated,change: created|updated) andmetrics({at, window_seconds: 60, requests, errors, error_rate, latency_p95_ms}at every tick); each event has anid:cursor sent back asLast-Event-IDto resume; 400invalid_requeston an unknown status or level, 403forbiddenoutside the roles. - Audit Gateway and Evidence Service (kit section 11, bricks G-11 and G-14; every read, search, verification, payload decryption and export below and under
/v1/auditwrites aneai.audit_access_logrow and its own audit eventaudit.read|search|verify|export.read|payload.decrypt|seal.read):- GET
/v1/evidence/seals(rolesauditor,security_officer,tenant_admin), POST/v1/evidence/seals(auditor,security_officer; 201 signed seal{seal_number, seq_from, seq_to, merkle_root, prev_seal_hash, seal_hash, alg: Ed25519, key_id, signature, sealed_at}; 409audit_nothing_to_seal; 503audit_seal_key_unavailable), GET/v1/evidence/seals/{id}/verify(readers;{seal, valid, failures}) - GET
/v1/evidence/verify(readers; chain, seals, signatures and timestampsevent_time <= ingested_at <= sealed_at), GET/v1/evidence/bundle(auditor,security_officer; offline bundle and Ed25519 signed manifest, check withpython -m eai.domains.evidence.verify bundle.json; 503audit_seal_key_unavailable) - GET
/v1/evidence/alerts?status=open|acknowledged(readers; integrity alertschain_broken|sequence_gap|seal_invalid|outbox_missing|clock_skew), GET/v1/evidence/gateway(readers; outbox pending, delivered, targets) - POST
/v1/evidence/events/{event_id}/payload{reason}(onlyauditorandsecurity_officer, neverplatform_adminby inheritance; decrypted sensitive payload, audited; 404, 409audit_payload_tampered, 410audit_payload_purged, 503audit_payload_key_unavailable), GET/v1/evidence/access-log?limit=(auditor,security_officer) - POST
/v1/evidence/holds{case_id, reason, time_from?, time_to?}(auditor,security_officer,tenant_admin; 201), GET/v1/evidence/holds, POST/v1/evidence/holds/{id}/release{reason}(one approval; released at the second distinct approver; 409audit_hold_same_approver,audit_hold_not_active) - POST
/v1/evidence/retention/run{kind: retention|erasure, cutoff?, event_ids?, dry_run?}(security_officer,tenant_admin; held payloads are skipped; returns the purge evidence: counts, sequences, Merkle root of the purged digests), GET/v1/evidence/retention/evidence(readers) - POST
/v1/evidence/runs/{run_id}/components{step_seq?, components: [{kind: model|prompt|tool|skill|plugin, name, version, digest?, attributes?}]}(tenant_admin,agent_operator,developer; 201), GET/v1/evidence/runs/{run_id}/configuration(readers and the same runtime roles; agent version and manifest digest, components by kind, policy decisions of the run; 404)
- GET
FinOps (brick H)
- Pricing: GET/POST
/v1/finops/price-cards, GET/v1/finops/price-cards/{id}, POST/v1/finops/price-cards/{id}/retire, GET/v1/finops/fx-rates, PUT/v1/finops/fx-rates - Usage: POST
/v1/finops/usage(ingest Usage Event, computes measured cost), POST/v1/finops/usage/batch, GET/v1/finops/usage(filters), GET/v1/finops/summary?group_by=tenant|project|agent|model|provider|usage_type|day - Estimation: POST
/v1/finops/estimate - Quotas: GET/POST
/v1/quotas, PUT/DELETE/v1/quotas/{id}, POST/v1/quotas/reserve, POST/v1/quotas/{id}/commit, POST/v1/quotas/{id}/release, GET/v1/quotas/usage - GPU: POST
/v1/finops/gpu/samples, GET/v1/finops/gpu/allocation, GET/POST/v1/finops/allocation-policies - Reconciliation: POST
/v1/finops/reconciliation/imports(provider invoice/export: openai, anthropic, aws_cur, azure, gcp, generic_csv), GET/v1/finops/reconciliation/imports, POST/v1/finops/reconciliation/run, GET/v1/finops/reconciliation/report(estimated vs measured vs billed, variance, alerts) - Budgets alerts: GET
/v1/finops/alerts - Media, GPU and storage prices (LLM_-011): POST
/v1/finops/media-prices{unit: audio_second|image|video_second|gpu_second|storage_gb_month, provider, resource, unit_price, currency, effective_from, source}(rolesfinops,tenant_admin; dated revisions, 409media_price_exists, 422media_price_invalid), GET/v1/finops/media-prices?unit=(read roles), POST/v1/finops/media-costs/estimate{items: [{unit, provider, resource, quantity, at}]}(read roles andagent; each item carries its provenance, oramount: null,status: unavailable,reason: price_unknown, never zero) - Settings: GET/PUT
/v1/finops/settings(tenant currency, monthly budget, variance threshold, reservation lifetime)
Licensing (commercial plane, kit section 13)
- GET
/v1/license(any authenticated principal): non sensitive status{state: unlicensed | valid | expiring_soon | grace | expired, evaluated_at, edition, product_id, term_months, valid_from, valid_until, grace_days, grace_until, days_remaining, grace_days_remaining, renewal_scheduled, next_valid_from, installation_group_id, restricted_actions, never_restricted, banner: {level: none | info | warning | critical, code, message_i18n}}; no customer id, support reference or token.restricted_actionsis["distribution.upgrade", "marketplace.install"]in stateexpiredonly, else empty - POST
/v1/license/activations(platform_admin): body{token}(signed licence, JWS compact EdDSA, verified locally againstEAI_LICENSE_PUBLIC_KEYS, no network call); 201{activation: activated | already_active, license, support_contract, status};support_contractis the support contract recorded from the signed licence in the same transaction (tableeai.support_contracts, V168):{license_id, support_ref, installation_group_id, customer_id, level (signed edition), valid_from, valid_until, grace_until, superseded_by, superseded_from, version, created_by, created_at, updated_at, state: scheduled | active | grace | superseded | ended}, never a price. Errors: 422license_invalid_token,license_signature_invalid,license_unknown_key,license_invalid_payload,license_forbidden_field(price, amount, currency, seat, quota, limit or counter field),license_invalid_term(12 or 36 months matching the dates),license_environment_not_included; 409license_group_mismatch,license_conflict; 403forbidden,policy_denied. Every refusal is written to the licence history and the audit chain - GET
/v1/license/history(platform_admin,?limit&cursor):{installation: {installation_id, installation_group_id, group_bound_at, group_bound_by, created_at}, licenses: [...], support_contracts: [support_contract, ...] (ordered by valid_from; a renewal licence of the group replaces the previous contract from its own valid_from: superseded_by, superseded_from), items: [{id, license_id, kind: activated | rejected | warning | grace_started | expired | action_refused, details, actor, at}], next_cursor} - Expiry never stops anything: after the grace period only
distribution.upgradeandmarketplace.installare refused with 403license_expired(eai.domains.licensing.guard.require); see docs/operations/en/licensing.md
Context Authority, delegations, mandates and break glass (kit sections 7, 8.1, 8.2)
Effective rights of every action = user AND agent AND delegation AND resource (OpenFGA or builtin tuples) AND policy (OPA or builtin); a sub-agent always receives a restricted delegation. Contracts: execution-context, delegation-context, policy-decision, action-context, approval-request (contracts/v1). See docs/security/en/identity-context-delegation.md.
- GET
/v1/context/jwks(any authenticated principal):{keys: [JWK]}public keys of the Context Authority (active key, and retired keys inside their verification window); 503context_authority_unavailable - POST
/v1/context/issue(tenant principal){audience?: [string], ttl_seconds?, delegation_id?, run_id?}: 201{token, context_id, kid, alg: ES256 | EdDSA, issued_at, expires_at, audience, digest, context}.tokenis a JWS compact whose header and payload are RFC 8785 canonical JSON; payload claimsiss, aud, iat, exp, jti, sub, tid, ctx(ctx = Execution Context v1). The lifetime never exceeds the delegation. Errors: 400context_ttl_exceeded,context_wrong_audience; 403forbidden,delegation_revoked,delegation_expired,session_revoked,mandate_revoked; 503context_authority_unavailable(no key, unreadable key, OpenBao unreachable, revoked key: fail closed, refusal audited) - POST
/v1/context/verify(tenant principal){token, audience?}:{valid: true, context_id, kid, alg, claims, context}; binds the verified identity context to the audit events of the request. Errors: 401context_malformed,context_invalid_signature,context_unknown_key,context_wrong_issuer,context_wrong_audience,context_expired,context_revoked(unknown jti, revoked context or revoked delegation); 403context_tenant_mismatch; 503context_authority_unavailable. Every refusal is audited (context.verifydeny) - POST
/v1/context/{context_id}/revoke(issuer of the context,tenant_admin,security_officer):{context_id, revoked_at, revoked_by}; 404context_not_found - GET
/v1/context/keys(security_officer,auditor):{items: [{kid, alg, status: active | retired | revoked, first_used_at, last_used_at, retire_after, revoked_at, revoked_by, revoke_reason}]}(public metadata, never key material) - POST
/v1/context/keys/{kid}/revoke(platform_admin){reason}: the key stops verifying at once; signing with it is refused with 503. 404signing_key_not_found - POST
/v1/delegations(tenant principal, not an agent){delegate: agent:<name> | app:<id> | service:<name> | subagent:<name>, scopes, audience?, resources?, data_classification_max?, max_depth (0..10), redelegation_allowed, ttl_seconds? | expires_at?, session_id?, purpose}: 201 delegation{delegation_id, parent_delegation_id, delegation_chain, root_principal_id, root_principal_kind, on_behalf_of, delegator, delegate, scopes, audience, resources, data_classification_max, depth, max_depth, redelegation_allowed, session_id, mandate_id, break_glass_id, purpose, status: active | expired | revoked, expires_at, revoked_at, revoked_by, revoke_reason, created_by, created_at}. Patterns use*only. Errors: 403delegation_scope_exceeds(scopes not held by the caller),delegation_classification_exceeds,delegation_root_forbidden; 400delegation_ttl_exceeded(EAI_DELEGATION_MAX_TTL_SECONDS),invalid_request - GET
/v1/delegations(?status=active|expired|revoked&root_principal_id&limit&cursor; own delegations, all fortenant_admin,security_officer,auditor), GET/v1/delegations/{id}(addsverification: {valid, reason, chain, problems}; 404delegation_not_found) - POST
/v1/delegations/{id}/restrict(root principal, creator,agent,tenant_admin,security_officer){delegate, scopes?, audience?, resources?, data_classification_max?, max_depth?, redelegation_allowed?, ttl_seconds? | expires_at?, purpose}: 201 child delegation, always covered by its parent. Errors: 403delegation_scope_exceeds,delegation_classification_exceeds,delegation_expiry_exceeds,delegation_depth_exceeded,delegation_not_redelegable,delegation_revoked,delegation_expired,delegation_invalid - POST
/v1/delegations/{id}/revoke(root principal, creator,tenant_admin,security_officer){reason}:{delegation_id, revoked: [ids]}, cascades to every descendant and revokes their execution contexts; the next sensitive action of a run under it is refused with 403delegation_revoked - POST
/v1/delegations/revoke-principal(tenant_admin,security_officer){principal_id, reason}:{principal_id, revoked: [ids]}every delegation rooted at that user - POST
/v1/delegations/authorize(tenant principal){action, delegation_id? | run_id?, scope?, resource?: <type>:<id>, relation?, audience?, data_classification?}:{action_id, action, allow, layers: [{layer: chain | session | user | agent | delegation | classification | audience | resource | relationship | policy, allow, reason?}], subject: {principal_id, on_behalf_of, root_principal_id, delegate}, delegation: {delegation_id, chain, depth}, decisions: {policy: {decision_id, allow, engine, obligations}, openfga}, run_id, decided_at};allowis true only when every layer allows (relationship engine error: deny). Audited asauthz.authorize - POST
/v1/mandates(tenant_admin,developer,security_officer){agent, service_identity_id, schedule_id?, scopes, audience?, resources?, data_classification_max?, max_depth, purpose, ttl_days (maxEAI_MANDATE_MAX_TTL_DAYS)}: 201{mandate_id, agent, schedule_id, service_identity_id, scopes, audience, resources, data_classification_max, max_depth, purpose, status: pending | approved | rejected | revoked | expired, approval_id, requested_by, approved_at, expires_at, revoked_at, created_at}; approved through POST/v1/approvals/{approval_id}/decideby another person. Errors: 403delegation_scope_exceeds(wider than the service identity),delegation_classification_exceeds; 404service_identity_not_found,schedule_not_found; 409service_identity_disabled; 400mandate_ttl_exceeded. A scheduled run without an approved mandate is skipped (skipped: mandate_required, auditedschedule.rundeny) - GET
/v1/mandates(?agent&limit), GET/v1/mandates/{id}(404mandate_not_found), POST/v1/mandates/{id}/revoke(requester,tenant_admin,security_officer){reason}(revokes the delegations of the mandate; 409mandate_closed) - POST
/v1/break-glass(tenant principal){roles, scopes, reason (10 characters minimum), duration_seconds (60..EAI_BREAK_GLASS_MAX_SECONDS), beneficiary?, incident_ref?}: 201{grant_id, requester, beneficiary, roles, scopes, reason, incident_ref, duration_seconds, status: pending | active | expired | rejected | revoked, approval_id, activated_at, expires_at, revoked_at, revoked_by, created_at}; the approval needs a quorum of 2 distinct approvers (security_officerortenant_admin), never the requester or the beneficiary (403separation_of_duties, 409already_decided). Errors: 400break_glass_duration_exceeded,invalid_request - GET
/v1/break-glass(?status&limit), GET/v1/break-glass/{id}(expired automatically pastexpires_at, auditedbreak_glass.expire; 404break_glass_not_found), POST/v1/break-glass/{id}/revoke(requester,security_officer,tenant_admin; revokes the delegations created under the grant; 409break_glass_closed) - POST
/v1/break-glass/{id}/context(beneficiary){audience?, ttl_seconds?}: 201 signed context withauthorization.break_glass {grant_id, roles, scopes, approval_id, expires_at}, lifetime bounded by the grant; 409break_glass_not_active, 403forbidden - Runs: POST
/v1/agents/{id}/runsacceptsdelegation_id(a delegation granted toagent:<name>, else an implicit one from the caller: caller scopes intersected with the agent scopes, lower classification); 403delegation_delegate_mismatch, 503context_authority_unavailablewhenEAI_CONTEXT_REQUIREDis on without a key. POST/v1/runs/{id}/stepsof kindtool_call,model_call,subagent,actorapprovaland POST/v1/runs/{id}/subagentsre-validate the delegation chain, the session, the mandate and the policy first: 403delegation_revoked,delegation_expired,delegation_invalid,delegation_required,delegation_resource_denied,delegation_not_redelegable,session_revoked,mandate_revoked,policy_denied(audited deny) - Audit events written under a verified context are Audit Event 1.1:
on_behalf_of,root_principal_id,delegation {delegation_id, parent_delegation_id, chain, depth, delegate, mandate_id, break_glass_id},agent {agent_id, name, version, run_id, parent_run_id, depth, subagent},workload,session,causal {request_id, execution_id, action_id, trace_id, span_id, parent_event_id},authorization {decision_ids},approvals {approval_ids},execution_context {context_id}; hashed in the chain
SOC, AI-SDR and Kill Switch (kit section 11)
Typed reactions only (BLOCK_REQUEST, RATE_LIMIT, REQUIRE_APPROVAL, CANCEL_RUN, CANCEL_SUBAGENTS, SUSPEND_AGENT, SUSPEND_APPLICATION, DISABLE_TOOL, QUARANTINE_CONNECTOR, REVOKE_CREDENTIAL, DISABLE_MODEL_ROUTE, ISOLATE_NETWORK, TERMINATE_WORKLOAD, SUSPEND_IDENTITY, SUSPEND_TENANT, EMERGENCY_LOCKDOWN, RECOVER), never a command. Contracts security-incident and containment-order (contracts/v1). SOC roles: analyst security_officer; lead security_officer, tenant_admin; evidence readers security_officer, tenant_admin, auditor. Every SOC read is audited (soc.access, soc.evidence.access), every refusal too (soc.access.deny). The Kill Switch Registry (PostgreSQL) is read by every policy decision (403 policy_denied, reason kill_switch:<REACTION>:<type>:<ref>:<id>), by authentication (401 credential_revoked, 403 identity_suspended, 403 tenant_suspended) and by the agents runtime (steps and sub-agents: 403 policy_denied with decision_id and kill_switch_ids; POST /v1/agents/{id}/resume 409 kill_switch_active). See docs/security/en/soc-runbook.md.
- GET
/v1/soc/catalog(tenant principal):{reactions, targets: {<REACTION>: [target types]}, detections: {<kind>: {severity, requirement, playbook: [{reaction, target}]}}, response: {min_confidence, max_auto_blast_radius, manual_approval_blast_radius, always_approval, auto_reactions, targets}, roles} - POST
/v1/soc/detections(security_officer,agent, or a credential granted the scopesoc:detectionsexplicitly){kind, source: guard | dlp | egress | quota | plugins | runtime | authz | policy | audit | knowledge | mcp | falco | tetragon | opensearch | manual, severity?: sev1..sev4, confidence (0..1), occurred_at?, user?, agent?, run_id?, session_id?, data_classification?, resource?, targets?: {session, run, agent, identity, credential, workload, mcp, plugin, tool, connector, knowledge_base, model, application}, evidence?, fingerprint?}: 201{detection_id, incident_id, duplicate, correlated, risk: {severity, confidence, score, reasons}, orders: [containment order]}. Same user, else same agent, else tenant level detections withinEAI_SOC_CORRELATION_WINDOW_SECONDSshare one incident (categorysoc). Errors: 400unknown_detection_kind,invalid_request; 403forbidden - GET
/v1/soc/detections(?incident_id&kind&limit, evidence readers):{items, next_cursor} - POST
/v1/soc/webhooks/{source}(falco,tetragon,opensearch; no principal, body: one event or a list of at most 200): headersX-EAI-Tenant(slug),X-EAI-Signature: t=<unix>,v1=<hex HMAC-SHA256(EAI_SOC_WEBHOOK_SECRET, "<t>." + body)>,X-EAI-Delivery; 202{received, items: [detection result]}. Errors: 401invalid_signature(also without secret),webhook_replay(outside 5 minutes); 409webhook_duplicate; 404 unknown source. Every delivery is recorded and audited (soc.webhook.detection) - POST
/v1/soc/receipts(in-cluster receipt reporter, same signature scheme, no principal){kill_switch_id, verified, adapter, evidence}: 202{kill_switch}; only forISOLATE_NETWORKandTERMINATE_WORKLOAD(409receipt_not_expected, 404not_found);verified: truemoves the switch toSUSPENDED_VERIFIED, false toVERIFICATION_FAILED - POST
/v1/soc/orders(analyst or lead){reaction, target: {type, ref}, incident_id?, reason, params?: {max_per_minute | selector | replicas | instances | kind}, max_blast_radius?, confidence?}: 201 containment order{id, incident_id, reaction, target_type, target_ref, params, blast_radius, max_blast_radius, confidence, severity, mode: manual | automatic, status: applied | pending_approval, response_decision: {allow, reasons, obligations, engine, decision_id}, manifest, reason, requested_by, approved_by, kill_switch?: {id, desired_state: SUSPENDED | RELEASED, observed_state: PENDING | APPLIED_PENDING_RECEIPT | SUSPENDED_VERIFIED | VERIFICATION_FAILED | RELEASED, last_receipt_id, last_verified_at, ...}, existing?}.EMERGENCY_LOCKDOWNandSUSPEND_TENANTalways wait for a second person;ISOLATE_NETWORKreturns a CiliumNetworkPolicy andTERMINATE_WORKLOADa scale to zero patch inmanifestfor the GitOps path. Errors: 400unknown_reaction,target_not_allowed,invalid_request; 409containment_refused(blast_radius_exceeded, ...); 422recover_requires_workflow; 403forbidden - GET
/v1/soc/orders(?status&incident_id&limit, evidence readers), GET/v1/soc/orders/{order_id}(adds the switch andreceipts; 404not_found) - POST
/v1/soc/orders/{order_id}/approve(lead, never the requester): applied order; 409order_not_pending,separation_of_duties. POST/v1/soc/orders/{order_id}/reject(lead){reason} - GET
/v1/soc/kill-switches(?state=active|all, evidence readers), POST/v1/soc/kill-switches/{switch_id}/verify(analyst or lead): probes the switch now and records a receipt - POST
/v1/soc/recoveries(analyst or lead){kill_switch_id, reason}: 201{id, kill_switch_id, reason, status: pending | approved | recovered | rejected, approvals, requested_by, reauthorized_by, reauthorization}; 409kill_switch_not_active,recovery_already_open. GET/v1/soc/recoveries(?status, evidence readers) - POST
/v1/soc/recoveries/{recovery_id}/approve(lead){reason?}: two distinct approvers, never the requester (409separation_of_duties,already_approved,recovery_not_pending) - POST
/v1/soc/recoveries/{recovery_id}/reauthorize(one of the approvers): releases the switch, re-evaluates the target with the policy decision point, then restores it (an agent is resumed); 409recovery_not_approved, 403reauthorization_by_approver,reauthorization_denied - GET
/v1/soc/incidents/{incident_id}/evidence(evidence readers):{incident_id, items: [{seq, kind, content, content_sha256, prev_hash, hash, sealed_by, sealed_at}], chain: {valid, evidence_count, custody_count, head}, custody_seq}; each read is a custody entry. POST/v1/soc/incidents/{incident_id}/evidence(analyst or lead){kind, content}: 201 sealed record - POST
/v1/soc/incidents/{incident_id}/evidence/export(evidence readers):{incident, items, chain, custody, custody_seq}; 422secret_detected(findings: [{type, path}], never the value) when the bundle holds a credential. GET/v1/soc/incidents/{incident_id}/custody(evidence readers):{incident_id, items: [{seq, action: seal | access | export, actor, detail, prev_hash, hash, at}], chain} - GET
/v1/soc/transactions(?status=in_flight|committed|failed|interrupted|reconciled&run_id, evidence readers): side effecting calls with their idempotency key and transaction id. POST/v1/soc/transactions/{transaction_id}/reconcile(lead){outcome: committed | not_executed, provider_reference?, note?}: 409transaction_not_reconcilable; a retry with the same key then returns the stored result (committed) or 409transaction_reconciled_not_executed, never a second call
Client object storage (brick E-13, kit section 6.3)
The client chooses the backend; every capability is discovered by real calls and stored with its verification time, never assumed. Contract storage-profile (contracts/v1). Providers aws_s3, ibm_cos, seaweedfs, s3_compatible (S3 REST API, auth sigv4 or, for IBM COS, ibm_iam), azure_blob (shared_key, sas, entra_client_secret, workload_identity for platform profiles only), gcs (service_account). A profile never holds a secret value: credential_ref is openbao://<path> (tenant secret written with POST /v1/secrets) or env://EAI_CRED_<NAME> (platform profiles). Endpoints, token endpoints and presigned URLs must be https outside the dev and test environments (422 storage_tls_required); calls leave through the egress guard (403 egress_denied). Capabilities: versioning, object_lock, legal_hold, retention, multipart, presign, sse, kms, storage_classes, lifecycle, replication, integrity, each {supported: true | false | null, method, detail, error?, checked_at}. A usage that needs a capability not verified true, or verified more than EAI_STORAGE_CAPABILITY_MAX_AGE_SECONDS ago, is refused with 409 storage_capability_missing {capability, reason: unsupported | unknown | not_verified | stale, profile, usage} and audited storage.capability.refused. Every action is decided by policy first (storage.profile.create, storage.profile.update, storage.profile.delete, storage.profile.verify, storage.presign.read, storage.presign.write; 403 policy_denied) and audited. Guide: docs/en/guides/storage-administration.md.
- GET
/v1/storage/profiles(tenant_admin,security_officer,developer,auditor;platform_adminalso sees the platform profiles;?scope=tenant|platform&provider&purpose&limit):{items: [profile], next_cursor} - POST
/v1/storage/profiles(tenant_adminforscope: tenant,platform_adminforscope: platform){name, scope?, description?, provider, endpoint?, region?, bucket, key_prefix?, auth: {method, account?, tenant_id?, client_id?, service_instance_id?, token_url?, scope?}, credential_ref, encryption?: {mode: provider_default | sse-s3 | sse-kms | encryption-scope | cmek, kms_key_id?, encryption_scope?}, options?: {path_style?, storage_class?}, purposes?: [audit_worm | evidence | documents | media | exports | backups | general], required_capabilities?, presign_prefixes?}: 201 profile{id, tenant_id, scope, name, provider, endpoint, region, bucket, key_prefix, auth, credential_ref, encryption, options, purposes, required_capabilities, presign_prefixes, needed_capabilities, status: unverified | verified | degraded | failed | disabled, capabilities, missing_capabilities, verified_at, last_error, version, created_by, created_at, updated_at}. Errors: 422storage_secret_in_profile,storage_credential_ref_invalid,storage_profile_invalid,storage_auth_method_unsupported,storage_provider_unsupported,storage_endpoint_required,storage_tls_required,validation_error; 409storage_profile_exists; 403storage_scope_forbidden,forbidden - GET
/v1/storage/profiles/{profile_id}(same readers; 404storage_profile_not_found, also for a profile of another tenant or a platform profile seen by a tenant) - PATCH
/v1/storage/profiles/{profile_id}(manager of the scope){description?, endpoint?, region?, bucket?, key_prefix?, auth?, credential_ref?, encryption?, options?, purposes?, required_capabilities?, presign_prefixes?, status?: active | disabled}: a change of provider, endpoint, region, bucket, auth, credential_ref, encryption or options clears the capabilities (status: unverified); adding the purposeaudit_wormto a verified profile without object lock is refused (409storage_capability_missing) - DELETE
/v1/storage/profiles/{profile_id}(manager of the scope): 204; the exports already written keep their objects (audit_exports.storage_profile_idset to null) - POST
/v1/storage/profiles/{profile_id}/verify(tenant_admin,security_officer;platform_adminfor a platform profile){probe_write?: true}:{profile, status: verified | degraded | failed, missing_capabilities, capabilities, error, duration_ms}. Real calls: S3 GetBucketVersioning, GetObjectLockConfiguration, GetBucketEncryption, GetBucketLifecycleConfiguration, GetBucketReplication, a multipart upload initiated then aborted, a presigned GET; Azure Get Container Properties, Get Account Information, a SAS request; GCS buckets.get, a resumable session opened then cancelled, a signed GET; withprobe_writea probe object under.eai-probe/written with the configured encryption, storage class and legal hold, read back and removed (skipped on a bucket with default retention or immutability).degradedlists the capabilities the purposes orrequired_capabilitiesneed (audit_worm: object_lock, retention, integrity);failedkeepserror(storage_credential_invalid,storage_auth_failed,storage_unreachable,storage_backend_error,egress_denied). 409storage_profile_disabled - GET
/v1/storage/profiles/{profile_id}/checks(same readers,?limit):{items: [{id, profile_id, status, capabilities, missing_capabilities, probe_write, error, duration_ms, checked_by, checked_at}]} - POST
/v1/storage/profiles/{profile_id}/presign(tenant_admin,developer,agent;platform_adminfor a platform profile){scope: read | write, key, expires_seconds? (default 900, at most EAI_STORAGE_PRESIGN_MAX_SECONDS), content_type?, purpose?}: 201{grant_id, url, method: GET | PUT, scope, key, expires_seconds, expires_at}(S3 SigV4 presigned URL, Azure service or user delegation SAS, GCS V4 signed URL; a read URL cannot write and the reverse). The URL is returned once: only its sha256, key, scope and expiry are stored (storage_presigned_grants). Errors: 403storage_key_out_of_scope(key outsidepresign_prefixes), 422storage_presign_expiry_invalid, 409storage_capability_missing(presign not verified, IBM IAM or Azure SAS credentials) - GET
/v1/storage/profiles/{profile_id}/objects(B9.3storage.list;tenant_admin,developer,agent,auditor;platform_adminfor a platform profile)?prefix (inside presign_prefixes, optional when the profile has exactly one), limit (1 to 500), cursor:{items: [{key, object_key, size, etag, content_type, sha256, version_id, last_modified, storage_class}], next_cursor, prefix}(keyis relative to the profilekey_prefix, usable with presign and delete;object_keyis the stored key); decided by policy first (storage.object.list), auditedstorage.object.list. Errors: 403storage_key_out_of_scope, 422storage_prefix_required, 409storage_profile_disabled - POST
/v1/storage/profiles/{profile_id}/objects/delete(B9.3storage.delete;tenant_admin,developer,agent;platform_adminfor a platform profile){key, version_id?}:{deleted: true, key, version_id}; decided by policy first (storage.object.delete), auditedstorage.object.delete. A profile with the purposeaudit_wormorevidencenever deletes through the API (409storage_object_protected) and a retention refusal of the backend is returned as such. Errors: 403storage_key_out_of_scope, 409storage_object_protected,storage_profile_disabled - WORM audit export (AUDI-011): POST
/v1/audit/exportand theaudit_wormworker job write through the tenant profile with purposeaudit_worm, else the platform profile namedEAI_AUDIT_WORM_STORAGE_PROFILE, else the legacyEAI_S3_*store (manifestworm.qualified: false). Through a profile the export needsobject_lockandretentionverified (409storage_capability_missing), each object is written in compliance mode and accepted only when the backend reports the retention back (502storage_worm_not_honoured); the manifest carriesworm: {qualified: true, storage_profile, object_lock, version_id, capabilities_verified_at}andaudit_exports.worm_qualifiedis true; 404storage_profile_not_foundwhen the configured platform profile does not exist
File security (ClamAV and YARA-X before any ingestion)
Every file that enters the platform follows Upload -> Quarantine -> ClamAV + YARA-X -> Validation -> Docling -> RAG / AI, fail closed. The record is created quarantined before any analysis (a crash leaves it quarantined, never released), the builtin checks run first (size, magic type against allowed_types, declared type against the detected type with block_type_mismatch, encrypted ZIP, OOXML or PDF, archives with allow_archives, archive nesting depth, declared member and total sizes read from the central directory of every ZIP container, nested ones included, without decompressing: EAI_FILE_SCAN_ARCHIVE_MAX_MEMBER_BYTES and EAI_FILE_SCAN_ARCHIVE_MAX_TOTAL_BYTES, unscannable beyond), then every ClamAV gateway and YARA-X worker over HTTPS with failover across all their endpoints (EAI_CLAMAV_URLS, EAI_YARAX_URLS, Bearer keys, EAI_FILE_SCAN_CA_FILE; a gateway LIMIT answer, a 413, INSTREAM size limit exceeded or a file above the advertised limits.stream_max_length is unscannable without failover; signatures of unknown age count as stale). Decision record: ADR-0166. Verdict precedence: malware > suspicious > unscannable > policy_violation > scan_error > clean; the record is released only when clean (YARA-X action: report matches and macros allowed by the policy are reported in reasons and released), else blocked. EAI_FILE_SCAN_MODE: enforce (both engines required, none reachable = 503) or dev (refused in production: an engine left unset is not_configured, a configured engine applies as in enforce). Only a released scan mints the ScanClearance that Docling, OCR, the media store and the transcription path require (same tenant, same sha256). Gated entry points: POST /v1/knowledge-bases/{id}/documents (source knowledge, connector with source_connector), POST /v1/media (media), the multimodal operations with content_base64 and the media written from adapter output (multimodal), POST /v1/gateway/audio/transcriptions (transcription), POST /v1/marketplace/packages and POST /v1/file-security/scans (api). Ingestion errors: 422 file_blocked {scan_id, verdict, reasons}, 503 file_scan_unavailable {scan_id}. Audit events file_security.scan (every scan), file_security.policy.update, file_security.sample.purge; security events security.file.blocked (malware and suspicious verdicts become a SOC detection security_finding, sev1 for malware) and security.file.scan_unavailable. Metrics eai_file_scans_total{status, verdict, source}, eai_file_scan_duration_seconds{engine}, eai_file_scan_engine_up{engine, endpoint} and eai_file_scan_signature_age_hours (worker job file_scan_probe), eai_file_scan_engine_errors_total{engine, reason}. Readers: tenant_admin, security_officer, auditor (and platform_admin); rule writers: security_officer, tenant_admin.
- GET
/v1/file-security/status(readers):{mode: enforce | dev, ready, pipeline: [upload, quarantine, clamav, yara_x, validation, docling, rag], engines: {clamav: {configured, healthy, total, endpoints: [{address, status: up | down, version, signatures: {version, published_at, age_hours, stale}, limits: {max_file_size, max_scan_size, max_files, max_recursion, stream_max_length}, latency_ms, error}]}, yara_x: {configured, healthy, total, endpoints: [{address, status, version, rules: {version, sha256, count, loaded_at}, latency_ms, error}]}}, checked_at} - GET
/v1/file-security/policy(readers):{locked: {scan_before_ingestion: true, engines_required: [clamav, yara_x], fail_closed: true, block_malware: true, block_unscannable: true}, max_file_bytes, allowed_types, known_types, allow_archives, block_office_macros, block_type_mismatch, retain_samples, quarantine_retention_days, max_signature_age_hours, stale_signatures: warn | block, updated_at, updated_by} - PUT
/v1/file-security/policy(rule writers){max_file_bytes? (1024 to EAI_FILE_SCAN_MAX_BYTES), allowed_types? (subset of known_types), allow_archives?, block_office_macros?, block_type_mismatch?, retain_samples?, quarantine_retention_days? (1 to 365), max_signature_age_hours? (1 to 720), stale_signatures? (warn | block)}: effective policy; a locked field is 422file_security_rule_locked, an unknown field 422invalid_request; audited - POST
/v1/file-security/scans(any tenant principal) JSON{content_base64, filename?, mime_type?}or multipart/form-datafile(+filename,mime_type): 201 scan record, even blocked; nothing is ingested. Record:{id, status: quarantined | released | blocked, verdict: clean | malware | suspicious | unscannable | scan_error | policy_violation | null, reasons: ["clamav:<signature>", "yara_x:<rule>", "policy:type_not_allowed", "engine:clamav_unavailable", ...], source: knowledge | media | multimodal | transcription | connector | channel | api, source_ref, filename, mime_type, detected_type, size_bytes, sha256, engines: {clamav: {status: clean | infected | unscannable | error | not_configured, signature, endpoint, version, signatures_version, elapsed_ms}, yara_x: {status: clean | match | error | not_configured, matches: [{rule, namespace, tags, severity, action, verdict}], rules_version, rules_sha256, endpoint, elapsed_ms}}, sample_retained, rescan_of, created_by, created_at, scanned_at, expires_at} - GET
/v1/file-security/scans(?status&verdict&source&limit&cursor; readers see every scan, other principals their own):{items: [record], next_cursor}; GET/v1/file-security/scans/{scan_id}: record (404not_foundfor a scan the principal cannot see) - POST
/v1/file-security/scans/{scan_id}/rescan(rule writers): 201 new record withrescan_of, from the encrypted sample of a blocked file; never releases the original upload; 409file_sample_unavailablewithout a sample - DELETE
/v1/file-security/scans/{scan_id}/sample(rule writers):{scan_id, sample_retained: false}; audited. Samples are kept encrypted only for blocked files whenretain_samplesand purged afterquarantine_retention_daysby the worker jobfile_scan_samples_purge - GET
/v1/file-security/stats(?window_hours, default 24):{window_hours, total, by_status, by_verdict, by_source, last_blocked_at}
Marketplace of complete packs (kit section 12)
A pack is a Plugin manifest with spec.format: pack (contracts/v1/plugin-manifest.schema.json: license, compatibility {platform, sdk, runtimes}, dependencies, requestedPermissions [{scope, reason, sensitive?, classification?}], dataProtection, approvals {install, sensitive, production}, signedArtifact, docs {en, fr}, evaluations, tests, layout.omitted) plus the mandatory folders (agents, subagents, skills/*/SKILL.md, prompts, rules, policies/opa, policies/openfga, mcp/servers, mcp/tools, mcp/schemas, connectors, workflows, rag, evaluations, security, observability/alerts, tests/unit, tests/integration, tests/authz, tests/security, docs/fr, docs/en, examples). Built, signed and published with enterprise-ai plugin create|validate|build|sign|publish|install (SDK Python). A pack ships no dashboard (ADR-0169): an archive entry under observability/dashboards is refused, with an English and French message. Every route is decided by policy first (marketplace.publish, marketplace.promote, marketplace.publisher.add, marketplace.publisher.revoke, marketplace.install.request, marketplace.upgrade.request, marketplace.install, marketplace.upgrade, marketplace.rollback, marketplace.uninstall, marketplace.authorize; 403 policy_denied) and audited, refusals included. Guide: docs/en/guides/marketplace.md.
- GET
/v1/marketplace/packages(tenant principal,?domain):{items: [{id, name, domain, description, owner, latest_version, created_by, created_at, updated_at}], next_cursor} - POST
/v1/marketplace/packages(tenant_admin,developer){archive: base64 .tar.gz, signature: {value: "ecdsa-p256:<base64>" | "ed25519:<base64>", signer}, skill_signatures: {<skill name>: signature of the SKILL.md sha256}, sbom: SPDX 2.3, vulnerability_scan: {tool: trivy, digest, report}, test_report: {digest, results: {unit, integration, authz, security, opa: {passed, failed, errors}}}, evaluation_report: {digest, results: {<name>: {score}}}, oci?: {ref, digest}}: 201 version{id, package, version, manifest, digest, archive_size, files, signature, signature_status: verified, skill_signatures, sbom_digest, test_report, evaluation_report, oci_ref, oci_digest, license, gates: [{gate, passed, problems}], status: published, vulnerability_summary, published_by, created_at}. Gates:schema,signature(pack digest and every SKILL.md against an active publisher key),sbom(every file and its SHA-256),vulnerability_scan(bound to the digest, no CRITICAL or HIGH, no secret),tests,evaluations(above their threshold),licence(MIT, Apache-2.0 or declared),permissions(no unsafe scope such assecrets.*,platform.*, wildcards; every scope used by an object is requested),docs,secrets,provenance(signed in-toto Statement v1 with the SLSA provenance v1 predicate inprovenance: {statement, signature: {value, signer}}, subject = the pack digest, the SBOM bound as a byproduct, signed by the pack publisher),privacy(G6: PII detection and masking for a pack handling PII, DLP, residency,externalExportDefault: deny),security(signedArtifactRequired,sandboxRequired,dynamicAuthorization,auditRequiredall true),approvals(requiredForcovers install, upgrade, sensitivePermissions, production, resume),configuration(configSchemais a JSON Schema of an object),resilience(G9: bounded tool timeouts, idempotent side effects with an idempotency key, workflows stop or compensate on failure); the manifest must carrynamespace,category,publisher,displayName {en, fr}. Errors: 422publication_gate_failed(details.gates),marketplace_archive_invalid; 409marketplace_version_exists(a published version is immutable) - GET
/v1/marketplace/packages/{name}(addsversions), GET/v1/marketplace/packages/{name}/versions/{version}, GET/v1/marketplace/packages/{name}/versions/{version}/sbom({package, version, sbom_digest, sbom, vulnerability_scan}); 404marketplace_package_not_found,marketplace_version_not_found - POST
/v1/marketplace/packages/{name}/versions/{version}/production(tenant_admin,security_officer){approval_id?}: withoutapproval_id, checks the stored gates, the current signature and the OCI digest, then opens a four eyes approval (marketplace.promote, quorum 2 at least): 202{approval_id, status: pending_approval, quorum, checks}; with an approvedapproval_id: 200 version withstatus: production. Errors: 422publication_gate_failed(signature_current,oci_artifact, ...), 403approval_required,approval_mismatch - GET
/v1/marketplace/publishers(tenant principal): trusted publisher keys{items: [{id, name, algorithm: ed25519 | ecdsa-p256, status, scope: platform | tenant, created_at}]}; POST/v1/marketplace/publishers(tenant_admin,security_officer){name, public_key: PEM (cosign.pub) }: 201 (422publisher_invalid_key); POST/v1/marketplace/publishers/{name}/revoke(tenant_admin,security_officer):{publisher, revoked, status}(404publisher_not_found); a revoked key fails every later preflight and production gate - POST
/v1/marketplace/preflight(tenant_admin,security_officer){package, version, environment: dev | test | staging | prod, approved_permissions, config?, secret_refs?: {<connector>: openbao://... | env://...}}:{package, version, environment, passed, checks: [{check, passed, ...}], requested, granted, not_granted, sensitive}; checkspackage_active,environment,config_schema(config validated against the packconfigSchema, 422config_invalidat install),version_available,production_approved(prod only),platform_compatibility,dependency:<name>,digest,signature,permissions_requested,permissions_safe,secret_ref:<connector>,no_conflict - GET
/v1/marketplace/installations(?environment), GET/v1/marketplace/installations/{id}({id, package, version, environment, status: pending_approval | installed | suspended | failed | uninstalled, requested_permissions, granted_permissions, config, secret_refs, preflight, smoke, created_objects, pending, installed_by, installed_at, ...}), GET/v1/marketplace/installations/{id}/events(request,install,upgrade,rollback,uninstall,smoke_failed,authorize_denied); 404installation_not_found(also for another tenant) - POST
/v1/marketplace/installations(tenant_admin) body of the preflight: preflight, then an approvalmarketplace.install(quorum ofapprovals.install, at least 2 when a granted permission is sensitive or the environment is prod; approverstenant_admin,security_officer, never the requester): 202{installation, approval_id, quorum, status: pending_approval, preflight}. Errors: 422permission_not_requested,preflight_failed(details.preflight); 403permission_unsafe; 409installation_exists - POST
/v1/marketplace/installations/{id}/apply(tenant_admin): once the approval is approved, preflight again, then creates the objects through the registries (agents with the granted scopes only, skills with their signatures, tools blocked when their scopes were not granted, connectors with the secret references, prompts, OPA policies as drafts, MCP servers registered in the MCP gateway whenconfig.mcp_servers.<name>.urlis given, a knowledge base per RAG template), named<object>-<environment>, records rules, OpenFGA models, MCP servers, workflows, RAG templates, dashboards, alerts and migrations inassets: [{kind, name, status: active | blocked | pending_endpoint | suspended, status_reason, digest}](a workflow whose tools are not granted isblocked), then smoke tests (including a real MCPinitializeandtools/listof every registered server); a failed smoke undoes the change (422smoke_failed). 200 installation. Errors: 403approval_required,approval_mismatch; 409installation_state,marketplace_object_conflict - POST
/v1/marketplace/installations/{id}/upgrade(tenant_admin){version, approved_permissions, config?, secret_refs?}: preflight and approval like an install (202), applied with/apply; the previous version and grant are kept. 409installation_state(not installed, or not a newer version) - POST
/v1/marketplace/installations/{id}/rollback(tenant_admin): reinstalls the previous version with its previous grant, removes what the newer version added; 409rollback_unavailable,installation_state, 422smoke_failed - POST
/v1/marketplace/installations/{id}/uninstall(tenant_admin): removes every object the installation created and its relationship tuples:{..., status: uninstalled, removed: [{kind, name}]} - POST
/v1/marketplace/installations/{id}/authorize(tenant_admin,developer,agent,service){permission}: runtime check used by the pack MCP servers and connectors;{installation_id, permission, allowed: true, environment}or 403permission_not_granted(details.requestedtells a requested but not granted permission), audited and recorded asauthorize_denied(also 403installation_suspended,permission_suspended,external_export_deniedwhen the permission sends pack data outside the platform anddataProtection.externalExportDefaultisdeny) - POST
/v1/marketplace/installations/{id}/status(tenant_admin,security_officer){status: suspended | installed, reason?, permissions?: [scope], approval_id?}: kill switch of an installation.suspendedis immediate: withpermissionsonly those capabilities are suspended (their tools blocked,/authorizerefuses them), without it the whole installation (every tool blocked, every agent suspended, MCP servers disabled, contents suspended); 200 installation withsuspension,suspended_permissions.installedresumes: withoutapproval_idit opens amarketplace.resumeapproval (quorum 2, never the requester): 202{approval_id, status: pending_approval, quorum}; with the approvedapproval_id: 200 installation, tools and agents back to the granted state. Errors: 409installation_state,package_suspended; 422permission_not_granted; 403approval_required,approval_mismatch - POST
/v1/marketplace/packages/{name}/status(tenant_admin,security_officer){status: suspended | active, reason?, approval_id?}: kill switch of a package.suspendedsuspends every installation of the tenant at once (suspended_installations) and refuses installs, upgrades, publications, promotions and resumes (409package_suspended);activelifts it only with an approvedmarketplace.package.resumeapproval (quorum 2; 202 withoutapproval_id), then resumes the installations it suspended (resumed_installations) - GET
/v1/marketplace/installations/{id}/supervision(tenant principal):{installation_id, status, environment, suspension, suspended_permissions, activity: {tool_invocations: {<status>: n}, runs: {<state>: n}, authorize_denied}, quality: {evaluations, smoke_passed, run_success_rate}, costs: [{currency, amount}], incidents: {open, items}, assets}aggregated from the tool invocations, runs, refusals and incidents of the objects the installation created - POST
/v1/marketplace/changes(tenant_admin,developer){kind: prompt | skill | connector | rule | model | policy | tool | agent | pack, ref, version, title?}: 201 change in stagedraft(ADR-0159) - GET
/v1/marketplace/changes?kind&ref&stageand GET/v1/marketplace/changes/{id}(tenant principal):{id, kind, ref, version, stage, evidence, history: [{stage, actor, at, approval_id?}], pending_approval_id}; 404change_not_found - POST
/v1/marketplace/changes/{id}/advance(tenant_admin,developer,security_officer){evidence?: {tests | staging_smoke | production_smoke: {passed, failed, errors}}, approval_id?}: next stage of draft, test, security_review, business_approval, staging, production, monitoring. Entering security_review needs a passingtestsresult, production a passingstaging_smoke, monitoring a passingproduction_smoke(422change_evidence_missing); entering business_approval, staging and production needs an approval (change.security_reviewby a security officer,change.business_approvalby a tenant administrator,change.productionquorum 2): 202{approval_id, target, quorum, status: pending_approval}, then 200 with the approvedapproval_id(403approval_required); 409change_closed; 403change_not_authorfor draft to test by someone else than the author - POST
/v1/marketplace/changes/{id}/reject(tenant_admin,security_officer){reason}: stagerejected, the change is closed - Applications (governed applications, third party agents): GET/POST
/v1/applications(POST tenant_admin, developer; kind agent_app, assistant, workflow, external_agent), GET/v1/applications/{app_id}(with deployments and statuses), POST/v1/applications/{app_id}/status{status: suspended|retired, reason}(tenant_admin, security_officer, immediate, audited), POST/v1/applications/{app_id}/deployments{version, environment}(draft plus approval request), POST/v1/applications/{app_id}/deployments/{deployment_id}/activate(only once approved, separation of duties), POST/v1/applications/{app_id}/external-agents{name, scopes}(tenant_admin; API key shown once, role agent, bound to the application: Tool and MCP gateways refuse an ungranted resource or a suspended application) - RAG Agent instances (activation and lifecycle of the Navin Enterprise RAG Agent, owned by the Control Plane; writes: tenant_admin or owner of the project, reads: tenant principals, another tenant's instance 404): POST
/v1/rag-agent/instances{id?, name, project, organization_id?, business_unit_id?, deployment_model: shared|dedicated, generation_model, embedding_model, version?}(201 draft, Idempotency-Key), GET/v1/rag-agent/instances(status,project,limit), GET/v1/rag-agent/instances/{instance_id}(record withstatus,serving,operation,run,validation,last_error,resourcesand thestepsof the current run), POST/v1/rag-agent/instances/{instance_id}/validate(real checks: project and caller, generation modelchat, embedding model, quota, Qdrant, shared runtime or Navin Manager; 422rag_agent_validation_failed), POST/v1/rag-agent/instances/{instance_id}/provision, POST/v1/rag-agent/instances/{instance_id}/start, POST/v1/rag-agent/instances/{instance_id}/stop, POST/v1/rag-agent/instances/{instance_id}/restart, POST/v1/rag-agent/instances/{instance_id}/upgrade{version}and DELETE/v1/rag-agent/instances/{instance_id}(202 long operations run by the worker jobrag_agent_lifecycle; statuses draft, validating, provisioning, starting, ready, degraded, failed, stopping, stopped, updating, deleting, deleted; steps validation, resources, database, object_storage, qdrant, memory, security, deployment, health, smoke, activation with states pending, running, done, failed, skipped; transition table, 409rag_agent_transition_invalid, repeats answerchanged: false, a retry resumes from the failed step, ready only after health and smoke; dedicated deployment through the platform_manager bridge, failed stepservice_not_publishedwhile the catalog servicenavin-rag-agentis not published; deletion of the owned resources only, shared ones listed as kept, governed application retired), GET/v1/rag-agent/instances/{instance_id}/health(runtime readiness, knowledge base and Qdrant, model registry: up, degraded, down), POST/v1/rag-agent/instances/{instance_id}/backupand POST/v1/rag-agent/instances/{instance_id}/restore{backup_id}(501rag_agent_backup_not_supportedwith the reason of each part); branding owned by the Control Plane (V209, the RAG runtime keeps a replica rewritten whenversionis newer; the record of an instance carriesbrandingandapp_url): GET/v1/rag-agent/instances/{instance_id}/branding(agent_name,sidebar_color,navbar_color,sidebar_text_color,navbar_text_color,logo{content_type, sha256, size_bytes, url} or null,version,updated_at,app_url), PUT/v1/rag-agent/instances/{instance_id}/branding{agent_name?, sidebar_color?, navbar_color?, app_url?, expected_version?}(null back to the default,#RRGGBBwith black or white text at 4.5:1 or more,app_urlhttps only 422rag_agent_app_url_invalid, 409rag_agent_branding_version_conflict, Idempotency-Key, auditedrag_agent.branding.update), POST/v1/rag-agent/instances/{instance_id}/branding/logo(multipartfile, PNG or JPEG only, SVG refused, signature, extension and Content-Type must match, 32 to 1024 pixels, 512 KiB, 422rag_agent_logo_invalid; file security scan fail closed 422file_blockedor 503file_scan_unavailable; stored through the storage domain in the documents profile underrag-agent/<tenant>/<instance>/branding/), GET/v1/rag-agent/instances/{instance_id}/branding/logo(bytes with the Content-Type of the validated format, nosniff, CSPdefault-src 'none') and DELETE/v1/rag-agent/instances/{instance_id}/branding/logo(default logo restored) - Compliance packs catalog (activation journey of the portal, read only, tenant principals): GET
/v1/compliance/packs(sector?;{available, reason, items[{code, version, sector, name, description, country, countries, activities, frameworks[{code, title, kind, country, issuer, requirements, disabled_by_default}]}], unreadable?}; read from the packs folder of the product,available: falsewithreason: packs_folder_not_foundwhen it is missing; the frameworks are never applicable by default, the selection is validated by the compliance officer in the runtime) - Compliance & Audit Agent instances (ADR-0177 section 8; activation and lifecycle of the Navin Enterprise Compliance & Audit Agent, owned by the Control Plane, a copy adapted from the RAG Agent model; writes: tenant_admin or owner of the project, reads: tenant principals, another tenant's instance 404
compliance_instance_not_found): POST/v1/compliance/instances{id?, name, project, organization_id?, business_unit_id?, sector: generic|banking|insurance|industry, pack?: banking|insurance|industry, deployment_model: shared|dedicated, generation_model, embedding_model, version?}(201 draft, Idempotency-Key), GET/v1/compliance/instances(status,project,sector,limit), GET/v1/compliance/instances/{instance_id}(record withstatus,serving,sector,pack,operation,run,validation,last_error,resourcesand thestepsof the current run), POST/v1/compliance/instances/{instance_id}/validate(real checks: project and caller, generation modelchat, embedding model, quota, Qdrant, shared runtime or Navin Manager; 422compliance_validation_failed), POST/v1/compliance/instances/{instance_id}/provision, POST/v1/compliance/instances/{instance_id}/start, POST/v1/compliance/instances/{instance_id}/stop, POST/v1/compliance/instances/{instance_id}/restart, POST/v1/compliance/instances/{instance_id}/upgrade{version}and DELETE/v1/compliance/instances/{instance_id}(202 long operations run by the worker jobcompliance_lifecycle; statuses draft, validating, provisioning, starting, ready, degraded, failed, stopping, stopped, updating, deleting, deleted; steps validation, resources, database, object_storage, qdrant, memory, security, deployment, health, smoke, activation with states pending, running, done, failed, skipped; transition table, 409compliance_transition_invalid, repeats answerchanged: false, a retry resumes from the failed step, ready only after health and smoke; the smoke step reads the version of the runtime in its OpenAPI documentGET /api/v1/compliance/openapi.json; dedicated deployment through the platform_manager bridge withsectorandpackin the service config, failed stepservice_not_publishedwhile the catalog servicenavin-compliance-agentis not published; deletion of the owned resources only, shared ones listed as kept, governed application retired), GET/v1/compliance/instances/{instance_id}/health(runtime readiness, knowledge base and Qdrant, model registry: up, degraded, down), POST/v1/compliance/instances/{instance_id}/backupand POST/v1/compliance/instances/{instance_id}/restore{backup_id}(501compliance_backup_not_supportedwith the reason of each part); branding owned by the Control Plane (V210, the compliance runtime keeps a replica rewritten whenversionis newer; the record of an instance carriesbrandingandapp_url): GET/v1/compliance/instances/{instance_id}/branding(agent_name,sidebar_color,navbar_color,sidebar_text_color,navbar_text_color,logo{content_type, sha256, size_bytes, url} or null,version,updated_at,app_url), PUT/v1/compliance/instances/{instance_id}/branding{agent_name?, sidebar_color?, navbar_color?, app_url?, expected_version?}(null back to the default,#RRGGBBwith black or white text at 4.5:1 or more else 422compliance_branding_contrast,app_urlhttps only 422compliance_app_url_invalid, 409compliance_branding_version_conflict, Idempotency-Key, auditedcompliance.branding.update), POST/v1/compliance/instances/{instance_id}/branding/logo(multipartfile, PNG or JPEG only, SVG refused, signature, extension and Content-Type must match, 32 to 1024 pixels, 512 KiB, 422compliance_logo_invalid; file security scan fail closed 422file_blockedor 503file_scan_unavailable; stored through the storage domain in the documents profile undercompliance-agent/<tenant>/<instance>/branding/), GET/v1/compliance/instances/{instance_id}/branding/logo(bytes with the Content-Type of the validated format, nosniff, CSPdefault-src 'none') and DELETE/v1/compliance/instances/{instance_id}/branding/logo(default logo restored) - Developer kit (what a developer gets for one application, agent or project): GET
/v1/developer/kit?application_id=(oragent_id, orproject: exactly one; tenant_admin, developer, application_owner; tenant scoped, 404 for another tenant's subject). One row per resource withvalue,state(ready, not_configured, service_not_installed) andaction(message_en,message_fr, portalhref): LLM gateway (LiteLLM: URL, allowed models, the env variable of the key), identity (Keycloak OIDC issuer, client id, token endpoint, scopes), permissions (OpenFGA store and model ids, OPA, decision endpoint, policies), data protection (DLP inspect and redact, Presidio), AI traceability (Opik, OTLP endpoint), business tools (Secure MCP Gateway: granted tools and servers), documents (Qdrant + secure RAG: the collections the app may read), SDK and user context, tests (Ragas, Promptfoo, garak). Plusenv(environment file entries) andnext_steps. Never a secret: the LiteLLM key is created on the LLM management page and shown once there
Platform Manager space of the console (ADR-0128)
Domain eai.domains.platform_manager, every route gated by platform_admin (403 otherwise); 503 platform_manager_not_configured when EAI_PLATFORM_MANAGER_URL is unset. Settings: EAI_PLATFORM_MANAGER_URL (also the operator egress allowlist entry), EAI_PLATFORM_MANAGER_CA_BUNDLE, EAI_PLATFORM_MANAGER_TOKEN_REF=secret://<tenant_id>/<path> (a Manager API token holding auth:delegate, stored with the secrets domain). Each call carries the portal user in X-NM-On-Behalf-Of: the Manager RBAC, approvals and stop rules apply; a Manager refusal is relayed with its status, a code platform_manager_<code> and message_i18n en/fr.
- GET
/v1/platform-manager/clusters:{items: [cluster]} - GET
/v1/platform-manager/summary:{items: [{cluster {id,name,mode}, services {total,healthy,warning,failed}, hosts, alerts, operations}]}(real counts) - GET
/v1/platform-manager/clusters/{cluster_id}/services:{items: [ServiceView]}; ServiceView =name, display_name, version, managed, desired_state, observed_state, health_status, operation_status, operation_id, security_state, admin_state (enabled|maintenance), instances {running,total}, drift - GET
/v1/platform-manager/clusters/{cluster_id}/services/{service}: ServiceView withsecurity_state {tls_enabled, secrets_set, secrets_total}and the Managersheet - GET
/v1/platform-manager/clusters/{cluster_id}/services/{service}/dashboard?from=&to=&step=(from and to ISO 8601, default the last hour; step 10 to 86400 seconds, default 60): the Manager monitoring dashboard of the service, relayed unchanged{service, delivery, managed, state, version, admin_state, window {from, to, step}, instances {total, running, stopped, failed, unknown, by_component}, health {status (ok|degraded|down|unknown), rollup, latest_checks, components}, availability {pct, samples, measured, counts, series}, hosts [{host_id, name, fqdn, address, state, components, metrics}], alerts {open, critical, warning, items}, operations, config {version, updated_at, drift_findings, stale_hosts}, connection}(cluster:readin the Manager; unknown service 404platform_manager_cluster_service_not_found) - GET
/v1/platform-manager/clusters/{cluster_id}/nodes:{items: [host]}; each host carrieshostname, fqdn, ip, state, os, arch, cpu_count, memory_mb, metrics, alerts, last_heartbeat_atandgpus: [{index, name, memory_mb, driver}](inventory reconciled from the agent facts) - GET
/v1/platform-manager/clusters/{cluster_id}/operations:{items: [operation]} - POST
/v1/platform-manager/clusters/{cluster_id}/services/{service}/operations{action: start|stop|restart|rolling_restart|reinstall, components?, hosts?, batch_size, tolerate_failures, approval_id?}: 202{operation_id, cluster_id, service, action}, auditedplatform_manager.<action>(allow or deny). Withoutcomponentsandhoststhe operation covers every component on every host the Manager detected; withoutapproval_idthe Manager uses the approval granted to the caller for this action and service (banking-secure). The portal only sends detected targets: the whole service, one machine or one component. - POST
/v1/platform-manager/clusters/{cluster_id}/services/{service}/mode{mode: drain|scale_to_zero|security_suspend|emergency_stop|disable|quarantine|maintenance|enable, reason?, drain_timeout_s? (0 to 3600), approval_id?}: 200{service, mode, operation_id (null when no process changes), effect {new_requests, in_flight, processes, stop_timeout_s, admin_state, security_state}, admin_state, admin_reason, security_state (authorized|revoked), run_authorized, security_lock}, auditedplatform_manager.mode.<mode>(allow or deny). The Manager enforces the state it leaves behind: 409platform_manager_service_admin_statefor an operation the state forbids, 423platform_manager_cluster_security_lockedwhile the cluster is under a security lock; the security suspension and lifting it needpki:managein the Manager. ServiceView also carriesadmin_state(enabled|disabled|maintenance|quarantined|suspended),admin_reasonandrun_authorized. - GET
/v1/platform-manager/catalog:{name, version, categories, items: [{name, display_name {en,fr}, description {en,fr}, category, version, components [{name, display_name, kind, cardinality, modes}], modes [{name, display_name, default}], requires, methods, sandbox_only, connection_type (postgresql|redis|... when the customer may already run it: catalogexternal.connection_type)?, delivery [install|connect|job|package], layer (functional|infrastructure), licence (SPDX), licence_note {en,fr}?, connect {schemes, default_port, credentials (required|optional|none), health {path, field?, expect_status, timeout_s}, fields [param]}?, job {runs {en,fr}, portal}?, library {coordinates [{ecosystem, name, version, install}], docs}?}]}, the services of the Manager catalog (stack): a server is[install, connect], an on demand job[job], a developer package[package]; the infrastructure layer (PostgreSQL, Redis, etcd, exporters...) stays installable and is shown apart - GET
/v1/platform-manager/catalog/{service}: install descriptor{name, display_name, description, category, version, methods, default_method, default_mode, sandbox_only, requires [{binding, service, connection_type, optional?, modes?}], connection_type?, sections, modes [{name, display_name, description, default, components [{name, display_name, kind, methods, cardinality {min, max?, recommended?, odd?, multiple_of?}}]}], parameters [{name, type, section, label, description, default?, enum?, min?, max?, required?, secret, generated, recommended}], delivery, layer, licence, licence_note?, connect?, job?, library?}; a secret parameter (passwordorsensitive) never carries a default; templates and artifacts are not returned (404platform_manager_service_unknownrelayed) - POST
/v1/platform-manager/clusters/{cluster_id}/services/validateInstallRequest ={services: [{name, mode?, method?, placements {component: [host_id]}, config {}, bindings {binding: {kind: managed|connection, ref}}, version?}] (1 to 20), start (default true), service_check (default true)}: 200{ok, errors: [{service, field (name|mode|method|placements.<component>|config.<param>|bindings.<binding>|plan), code, message_i18n {en,fr}}], plan {order, stages}}, a dry run of the Manager planner (services:installin the Manager) - POST
/v1/platform-manager/clusters/{cluster_id}/servicesInstallRequest: 202{operation_id, cluster_id, services}, the install operation started by the Manager on the chosen hosts (services:install; 422platform_manager_services_invalid|platform_manager_topology_invalid|platform_manager_config_invalidwithdetails.manager_details.errors), auditedplatform_manager.install(allow or deny) with the services, modes, methods, hosts and configured keys, never the configuration values; follow it with GET/v1/platform-manager/clusters/{cluster_id}/operations - POST
/v1/platform-manager/clusters/{cluster_id}/services/connect/validateConnectRequest ={name, endpoint (absolute http(s) URL, no user info or query), auth? {method (a method of connect.auth of the descriptor: none, api_key, bearer, basic, client_certificate, client_credentials, login...), fields {} (its fields, for example api_key, token, username and password, client_id and client_secret)}, mtls? {client_cert, client_key} (PEM, mutual TLS), ca_pem? (private certificate authority of the service), credentials_ref? (a reference such as secret://tenant/path, never a secret value), tls_verify (default true), config {}}: 200{ok, errors: [{service, field (name|endpoint|auth.method|auth.fields.<field>|auth|mtls.<field>|ca_pem|tls_verify|credentials_ref|config.<field>), code (service_unknown|delivery_not_connectable|service_already_installed|endpoint_invalid|endpoint_scheme|auth_required|auth_method_unknown|required|credentials_refused|pem_invalid|client_certificate_invalid|ca_invalid|production_https_required|production_tls_verify_required|production_auth_required|credentials_ref_invalid|unknown|...), message_i18n {en,fr}}], health {url, status, health (ok|warning|critical), latency_ms, error, auth {method, result (accepted|refused|unexpected|unreachable|not_checked|not_applicable), status, url, error}}|null}, a dry run of Configure & Connect against theconnectsection of the descriptor: the Manager runs the health check and an authenticated request of the method on the service itself, and credentials the service refuses are an error (services:installin the Manager) - POST
/v1/platform-manager/clusters/{cluster_id}/services/connectConnectRequest: 201 ServiceView withdelivery: connect,managed: false, no instance andconnection {endpoint, credentials_ref, tls_verify, ca_pem_set, config, auth {method, fields (public ones), secrets_set, client_cert_set}, mtls {client_cert_set}|null, health_details}: an existing service of the customer registered without hosts, its secret fields sealed by the Manager (never returned), health and credentials checked by the Manager (at registration and every minute), never installed, started, stopped or reinstalled (409platform_manager_service_unmanagedfor an operation); 422platform_manager_connect_invalidwithdetails.manager_details.errors; auditedplatform_manager.connect(allow or deny) with the endpoint, TLS, the method and the names of its fields and the configured keys, never a credential, the configuration values nor the reference - DELETE
/v1/platform-manager/clusters/{cluster_id}/services/{service}/connection: 200{service, disconnected: true}, forgets a connected service (the service itself is untouched; 409platform_manager_service_not_connectedfor an installed service,platform_manager_service_in_usewhile a consumer is bound to it), auditedplatform_manager.disconnect(allow or deny). ServiceView carriesdelivery (install|connect)andconnection(null for an installed service) - GET
/v1/platform-manager/connections?type=postgresql|redis|oidc:{items: [{id, name, type, params, secret {secret: true, set, name}, description, last_test, created_at, updated_at}]}, the existing PostgreSQL, Redis and OIDC issuers (Keycloak) of the customer declared as Manager connections (connections:readin the Manager); the secret value is never returned - POST
/v1/platform-manager/connections/validateConnectionTest ={type: postgresql|redis|oidc, params {} (postgresql: host, port, database, username, sslmode, ca_pem; redis: hosts [host:port], mode, sentinel_master, tls, username, database, ca_pem; oidc: issuer), secret? (write only)}: 200{ok, latency_ms, server_version, error_code, message_i18n {en,fr}}, the real protocol test by the Manager before saving, nothing stored (connections:manage; 422platform_manager_connection_params_invalidwithdetails.manager_details.fields), auditedplatform_manager.connection.validatewith the type, the parameter keys and whether a secret is set, never the secret - POST
/v1/platform-manager/connectionsConnectionTest + {name, description?}: 201 the connection (secret sealed by the Manager, read back as{secret: true, set: true}; 409platform_manager_connection_name_taken), auditedplatform_manager.connection.create. An install binds a service to it withbindings {<binding>: {kind: connection, ref: <connection id>}} - POST
/v1/platform-manager/connections/{connection_id}/test: 200 the protocol test of a saved connection (its sealed secret stays in the Manager), auditedplatform_manager.connection.test - DELETE
/v1/platform-manager/connections/{connection_id}: 204, forgets a connection (409platform_manager_connection_in_usewhile a service is bound to it), auditedplatform_manager.connection.delete - GET
/v1/platform-manager/console:{configured, console_url, consoles: [{key: opik|grafana|keycloak|litellm|opensearch, setting, configured, url, problem: null|https_required|credentials_in_url|manager_https_required|manager_credentials_in_url, source: setting|manager|null, service, cluster_id, cluster_name}], discovery: {ok, code}}: base URL of the Manager console for the deep links (EAI_PLATFORM_MANAGER_CONSOLE_URL, elseEAI_PLATFORM_MANAGER_URL) and the operator consoles (EAI_OPIK_UI_URL,EAI_GRAFANA_URL,EAI_KEYCLOAK_CONSOLE_URL,EAI_LITELLM_UI_URL,EAI_OPENSEARCH_DASHBOARDS_URL; no dashboard server console, the dashboards are in the Manager console and the portal, ADR-0169); a console without its setting comes from the Manager service of the same name (opik, keycloak, litellm, opensearch; never Grafana, not in the catalog): the endpoint of a connected service or the quick link of an installed one, read on behalf of the user (a Manager refusal givesdiscovery.okfalse and leaves the settings in force); https without credentials only, elseurlnull; never returns a credential (ADR-0162) - GET
/v1/platform-manager/clusters/{cluster_id}/services/{service}/config: current Manager configuration{version, properties, recommendations, groups, stale_hosts, ...}(secret values masked by the Manager,configs:read); GET/v1/platform-manager/clusters/{cluster_id}/services/{service}/config/versions:{items: [{version, note, created_by, created_at}]}. Editing, preview and rolling apply stay in the Manager console - GET
/v1/platform-manager/clusters/{cluster_id}/services/{service}/config/diff?from=&to=(versions >= 1):{cluster_id, service, from, to, items: [{param, from, to}]}, one entry per changed parameter; a secret parameter is{param, secret: true, changed: true, from {secret, set}, to {secret, set}}, never its value (configs:readin the Manager; unknown version 404 relayed). - IT vault (
eai.domains.platform_manager.vault, tableeai.it_vault_entriesV196,platform_adminonly, never calls the Manager): the credentials of the installed services. Only the name is stored in clear; URL, login, password and API key are one AES-256-GCM document (key HKDF-SHA256 of the platform master key, infoit-vault, random nonce, the entry id as associated data). VaultEntry ={id, name, url, login, has_password, has_api_key, source: manual|installed:<service>, created_by, updated_by, created_at, updated_at}(never the password nor the key) - GET
/v1/platform-manager/vault/entries:{items: [VaultEntry]}by name - POST
/v1/platform-manager/vault/entries{name (1..120), url?, login?, password?, api_key?}: 201 VaultEntry (409it_vault_name_taken), auditedit_vault.createwith the name only - GET
/v1/platform-manager/vault/entries/{entry_id}: VaultEntry (404it_vault_entry_not_found) - PATCH
/v1/platform-manager/vault/entries/{entry_id}{name?, url?, login?, password?, api_key?}(omitted field kept, empty password or key cleared): VaultEntry, auditedit_vault.updatewith the changed field names - DELETE
/v1/platform-manager/vault/entries/{entry_id}: 204, auditedit_vault.delete - POST
/v1/platform-manager/vault/entries/{entry_id}/reveal:{id, password, api_key}in clear for show and copy, auditedit_vault.reveal - POST
/v1/platform-manager/vault/import:{created: [name], skipped: [name]}, adds the services the platform is configured with (PostgreSQL, Redis, Keycloak, OpenBao, OpenFGA, OPA, LiteLLM, Qdrant, NATS, Temporal, LiveKit, Opik, ClickHouse, S3, Platform Manager, installer account of the portal; from theEAI_*settings, URL without credentials) that are not in the vault yet, never overwrites an entry; also run at the API start - GET
/v1/platform-manager/clusters/{cluster_id}/support-bundles:{items: [{id, status, size, sha256, contents, redactions, operation_id, created_at, finished_at}]}; POST/v1/platform-manager/clusters/{cluster_id}/support-bundles(no body): 202{cluster_id, operation_id, bundle_id}, a sanitized support bundle built by a Manager operation (cluster:managein the Manager), auditedplatform_manager.support_bundle(allow or deny); the archive is downloaded from the Manager console. - Backup and DR (Backup & Recovery Manager, kit 20.5 and 20.6, ADR-0162). Reads need
backup:readin the Manager, restoresrestore:execute, DRdr:manage: GET/v1/platform-manager/backup/plans({items: [policy], alerts: [late or failed policy]}), GET/v1/platform-manager/backup/jobs, GET/v1/platform-manager/backup/backups(artefacts withsha256, size, last_verification), GET/v1/platform-manager/backup/restores(restore plans withstatus, approvals, compatibility), GET/v1/platform-manager/dr/plans(rpo_target_seconds, rto_target_seconds, null until the client sets them), GET/v1/platform-manager/dr/exercises(rpo_observed_seconds, rto_observed_seconds, rpo_met, rto_met, null when not measured) - POST
/v1/platform-manager/backup/restores{artifact_id, environment: isolated|staging|production, target_versions?}: 201 restore plan (plannedorincompatiblewithcompatibility.problems); POST/v1/platform-manager/backup/restores/{plan_id}/approve: two approvers other than the requester (403platform_manager_restore_self_approval, 409platform_manager_restore_not_approvablefor an incompatible or closed plan); POST/v1/platform-manager/dr/exercises{plan_id}: 201 exerciserequested; POST/v1/platform-manager/dr/exercises/{exercise_id}/approve: another person approves (403platform_manager_restore_self_approval). Each write auditedplatform_manager.restore.plan|restore.approve|dr.exercise.request|dr.exercise.approve(allow or deny). Under delegation the Manager compares the delegated person, not the service account. Executing a restore, completing an exercise and a failover stay in the Manager console - Getting started (first page of the space). GET
/v1/platform-manager/setup:{states: [ok, missing, down, error], core: [check], manager}; check ={key: api|database|migrations|redis|keycloak, state, required, setting, message_i18n {en,fr}, action_i18n}(Redis and Keycloak are optional:requiredfalse); manager ={state, problem: null|url_missing|token_ref_missing|credential_missing|unreachable|egress_denied|token_invalid|delegation_forbidden|user_unknown|refused, setting, message_i18n, url, url_set, token_ref, token_ref_set, suggested_token_ref, secret_path, settings, credential_found, reachable, delegated_user, user_known, clusters, manager_code, status, required_permissions, runtime_change: false, restart_required: true}. Never a secret (the token reference only, never the token); never fails when the Manager is absent. The settingsEAI_PLATFORM_MANAGER_URLandEAI_PLATFORM_MANAGER_TOKEN_REFare read from the environment: changing them needs a restart. - POST
/v1/platform-manager/clusters{name (1 to 80), mode: production|sandbox, description?, profile?: development|standard|banking-secure|air-gapped|hybrid}: 201 the Manager cluster (clusters:createin the Manager, 409platform_manager_cluster_name_taken), auditedplatform_manager.cluster_create(allow or deny). - POST
/v1/platform-manager/clusters/{cluster_id}/hosts/ssh-bootstrap{hosts [fqdn] (1 to 200), ssh_user, ssh_port (default 22), private_key, host_key_fingerprints {host: "SHA256:..."}, use_sudo (default true)}: 202{operation_id, cluster_id, hosts}, a Manager operationhost.ssh_bootstrapinstalling and registering the node agent (hosts:manage; 422platform_manager_validation_errorfor a missing fingerprint). The private key is relayed to the Manager, which keeps it in memory for the bootstrap tasks; the Control Plane never stores it. Auditedplatform_manager.ssh_bootstrap(allow or deny) with hosts, user, port, sudo and fingerprints, never the key. - POST
/v1/platform-manager/clusters/{cluster_id}/enrollment-tokens{expires_in_hours (1 to 720, default 24), max_uses (1 to 10000, default 10)}: 201{id, cluster_id, token, expires_at, max_uses, register_command}(nm-agent register --server ... --token ... --ca-sha256 ...to run on each machine), shown once; auditedplatform_manager.enrollment_tokenwithid, expires_at, max_uses, never the token nor the command. GET/v1/platform-manager/clusters/{cluster_id}/enrollment-tokens:{items: [{id, prefix, expires_at, max_uses, uses, created_at, revoked_at}]}; DELETE/v1/platform-manager/clusters/{cluster_id}/enrollment-tokens/{token_id}: 204, auditedplatform_manager.enrollment_token_revoke. - GET
/v1/platform-manager/hosts/{host_id}: the machine withfacts,agent_version,last_heartbeat_at,state; GET/v1/platform-manager/hosts/{host_id}/preflight:{items: [{check, status, message_i18n, details, checked_at}]}; POST/v1/platform-manager/hosts/{host_id}/preflight, POST/v1/platform-manager/hosts/{host_id}/facts/refresh, POST/v1/platform-manager/hosts/{host_id}/decommission(no body): 202{operation_id, host_id}(hosts:manage; decommission 409platform_manager_host_has_components), auditedplatform_manager.preflight|facts_refresh|decommission(allow or deny).
Shared runtime semantics (every SDK and runtime MUST follow them)
- Heartbeat: POST
/v1/runs/{id}/heartbeatbody{"progress": {...}, "step": "..."}returns{"run_id": "...", "state": "<run state>", "agent_status": "active|suspended|killed", "action": "continue|stop", "reason": null}.actionisstopwhen the run is cancelled, suspended or terminal, or when the agent is suspended or killed (kill switch). A runtime that receivesstopends the turn, writes a checkpoint and exits without new model or tool calls. - Agent runtime architecture (Navin):
AgentLoopreceives messages (channels, sessions, schedules, events), prepares the context, manages sessions, resumes after a crash from the last checkpoint, handles stop requests, cron schedules and the heartbeat;AgentRunnerruns one turn: model call, then tool calls, repeated until the final answer, with explicit continuation rules (work remaining, user already said go, verify before finishing); sub-agents run in parallel and report results; the run plan advances automatically to the next ready step; a message bus links channels and the engine. LangGraph and Temporal are optional adapters, not the engine. - Background jobs:
python -m eai.workerruns the periodic platform jobs (scheduler tick, event outbox drain, quota reservation expiry, stale heartbeats, retention) with a PostgreSQL advisory lock so a single replica is active.
Telephony (ADR-0173)
- Telephony: Navin Enterprise configures the engines installed by the Navin Manager (Asterisk through AMI and ARI, Kamailio through JSONRPC, LiveKit through its API); writers platform_admin and tenant_admin, readers also support_engineer and auditor; secrets are write only (
has_*flags); every change is audited. - Telephony active calls: GET
/v1/telephony/active-calls(List the active calls), POST/v1/telephony/active-calls/{linkedid}/hangup(Hang up an active call), POST/v1/telephony/active-calls/{linkedid}/transfer(Transfer an active call) - Telephony human agents: GET
/v1/telephony/agents(List the human agents), POST/v1/telephony/agents(Create a human agent), GET/v1/telephony/agents/{item_id}(Read a human agent), PATCH/v1/telephony/agents/{item_id}(Change a human agent), DELETE/v1/telephony/agents/{item_id}(Delete a human agent) - Telephony fraud blocks: GET
/v1/telephony/blocks(List the blocks), POST/v1/telephony/blocks(Block the tenant or an extension), POST/v1/telephony/blocks/{block_id}/lift(Lift a block) - Telephony calls: GET
/v1/telephony/calls(List the calls), GET/v1/telephony/calls/{call_id}(Read a call) - Telephony conference sessions: GET
/v1/telephony/conference-sessions(List the conference sessions), GET/v1/telephony/conference-sessions/{session_id}(Read a conference session) - Telephony conferences: GET
/v1/telephony/conferences(List the conference rooms), POST/v1/telephony/conferences(Create a conference room), GET/v1/telephony/conferences/{item_id}(Read a conference room), PATCH/v1/telephony/conferences/{item_id}(Change a conference room), DELETE/v1/telephony/conferences/{item_id}(Delete a conference room) - Telephony configuration versions: GET
/v1/telephony/config-versions/{version_id}(Read a configuration version) - Telephony AMI event streams: GET
/v1/telephony/event-streams(List the AMI event connections) - Telephony extensions: GET
/v1/telephony/extensions(List the extensions), POST/v1/telephony/extensions(Create an extension), GET/v1/telephony/extensions/{item_id}(Read an extension), PATCH/v1/telephony/extensions/{item_id}(Change an extension), DELETE/v1/telephony/extensions/{item_id}(Delete an extension) - Telephony fraud alerts: GET
/v1/telephony/fraud-alerts(List the fraud alerts), POST/v1/telephony/fraud-alerts/{alert_id}/acknowledge(Acknowledge a fraud alert) - Telephony infrastructure (platform_admin): GET
/v1/telephony/infrastructure(List the telephony infrastructure of the Navin Manager), POST/v1/telephony/infrastructure/pools(Create or refresh a PBX pool from the Manager inventory) - Telephony IVR flows: GET
/v1/telephony/ivr-flows(List the IVR flows), POST/v1/telephony/ivr-flows(Create an IVR flow), GET/v1/telephony/ivr-flows/{flow_id}/versions(List the versions of an IVR flow), POST/v1/telephony/ivr-flows/{flow_id}/versions(Create a version of an IVR flow), GET/v1/telephony/ivr-flows/{flow_id}/versions/{version}(Read a version of an IVR flow), POST/v1/telephony/ivr-flows/{flow_id}/versions/{version}/publish(Publish a version of an IVR flow), GET/v1/telephony/ivr-flows/{item_id}(Read an IVR flow), PATCH/v1/telephony/ivr-flows/{item_id}(Change an IVR flow), DELETE/v1/telephony/ivr-flows/{item_id}(Delete an IVR flow) - Telephony IVR prompts: GET
/v1/telephony/ivr-prompts(List the IVR prompts), POST/v1/telephony/ivr-prompts(Upload an IVR prompt), GET/v1/telephony/ivr-prompts/{prompt_id}(Read an IVR prompt), DELETE/v1/telephony/ivr-prompts/{prompt_id}(Delete an IVR prompt), POST/v1/telephony/ivr-prompts/{prompt_id}/download(Get a download link of an IVR prompt) - Telephony phone numbers (DID): GET
/v1/telephony/numbers(List the phone numbers), POST/v1/telephony/numbers(Create a phone number), GET/v1/telephony/numbers/{item_id}(Read a phone number), PATCH/v1/telephony/numbers/{item_id}(Change a phone number), DELETE/v1/telephony/numbers/{item_id}(Delete a phone number) - Telephony PBX pools: GET
/v1/telephony/pbx-pools(List the PBX pools), POST/v1/telephony/pbx-pools(Declare a PBX pool), GET/v1/telephony/pbx-pools/{pool_id}(Read a PBX pool), PATCH/v1/telephony/pbx-pools/{pool_id}(Change a PBX pool), DELETE/v1/telephony/pbx-pools/{pool_id}(Delete a PBX pool), POST/v1/telephony/pbx-pools/{pool_id}/drain(Drain or restore a dispatcher destination), POST/v1/telephony/pbx-pools/{pool_id}/preview(Preview the configuration of a pool), POST/v1/telephony/pbx-pools/{pool_id}/publish(Publish the configuration of a pool), POST/v1/telephony/pbx-pools/{pool_id}/reconcile(Reconcile the routing of a pool), POST/v1/telephony/pbx-pools/{pool_id}/rollback(Roll a pool back to a version), GET/v1/telephony/pbx-pools/{pool_id}/versions(List the configuration versions of a pool) - Telephony configuration preview: POST
/v1/telephony/preview(Preview the configuration of the tenant) - Telephony queues: GET
/v1/telephony/queues(List the call queues), POST/v1/telephony/queues(Create a call queue), GET/v1/telephony/queues/{item_id}(Read a call queue), PATCH/v1/telephony/queues/{item_id}(Change a call queue), DELETE/v1/telephony/queues/{item_id}(Delete a call queue), GET/v1/telephony/queues/{queue_id}/members(List the members of a queue), POST/v1/telephony/queues/{queue_id}/members(Add a member to a queue), PATCH/v1/telephony/queues/{queue_id}/members/{member_id}(Change the penalty of a queue member), DELETE/v1/telephony/queues/{queue_id}/members/{member_id}(Remove a member from a queue) - Telephony quotas: GET
/v1/telephony/quotas(Read the telephony quotas), PUT/v1/telephony/quotas(Set the telephony quotas) - Telephony recordings: GET
/v1/telephony/recordings(List the recordings), GET/v1/telephony/recordings/{recording_id}(Read a recording), DELETE/v1/telephony/recordings/{recording_id}(Delete a recording), POST/v1/telephony/recordings/{recording_id}/download(Get a download link), PUT/v1/telephony/recordings/{recording_id}/legal-hold(Set the legal hold of a recording) - Telephony routing rules: GET
/v1/telephony/routing-rules(List the routing rules), POST/v1/telephony/routing-rules(Create a routing rule), GET/v1/telephony/routing-rules/{item_id}(Read a routing rule), PATCH/v1/telephony/routing-rules/{item_id}(Change a routing rule), DELETE/v1/telephony/routing-rules/{item_id}(Delete a routing rule) - Telephony runtime settings: GET
/v1/telephony/runtime-settings(Read the runtime settings), PUT/v1/telephony/runtime-settings(Set the runtime settings) - Telephony settings: GET
/v1/telephony/settings(Read the telephony settings), PUT/v1/telephony/settings(Set the telephony settings) - Telephony live statistics: GET
/v1/telephony/stats(Read the live statistics) - Telephony SIP trunks: GET
/v1/telephony/trunks(List the SIP trunks), POST/v1/telephony/trunks(Create a SIP trunk), GET/v1/telephony/trunks/{item_id}(Read a SIP trunk), PATCH/v1/telephony/trunks/{item_id}(Change a SIP trunk), DELETE/v1/telephony/trunks/{item_id}(Delete a SIP trunk) - Telephony AI voice agents: GET
/v1/telephony/voice-agents(List the AI voice agents), POST/v1/telephony/voice-agents(Create an AI voice agent), GET/v1/telephony/voice-agents/{item_id}(Read an AI voice agent), PATCH/v1/telephony/voice-agents/{item_id}(Change an AI voice agent), DELETE/v1/telephony/voice-agents/{item_id}(Delete an AI voice agent) - Telephony voicemail: GET
/v1/telephony/voicemail(List the voicemail messages), DELETE/v1/telephony/voicemail/{message_id}(Delete a voicemail message), POST/v1/telephony/voicemail/{message_id}/listen(Listen to a voicemail message), POST/v1/telephony/voicemail/{message_id}/read(Mark a voicemail message read) - Telephony WebRTC softphone: POST
/v1/telephony/webrtc/credentials(Get WebRTC credentials) - Telephony machine routes, outside the SDKs (called by LiveKit, by Asterisk or by the voice workers with their own token): POST on
/v1/telephony/calls/{call_id}/events(Report a call event), POST on/v1/telephony/calls/{call_id}/tools/{name}(Run a lookup tool), POST on/v1/telephony/calls/{call_id}/transfer(Transfer a call to a human), POST on/v1/telephony/livekit/webhook(Receive a LiveKit webhook), GET on/v1/telephony/media/prompts/{tenant_id}/{prompt_file}(Play an IVR prompt), GET on/v1/telephony/voice-agents/{voice_agent_id}/runtime(Read the runtime of a voice agent)
Backend conventions (all backend contributors)
Layout under services/control-plane/:
src/eai/main.py create_app(); includes every router found in eai/domains/*/ (module attribute `router`)
src/eai/config.py Settings (pydantic-settings, env prefix EAI_)
src/eai/db.py pool, `tx(principal=None)` context manager -> psycopg connection with `SET LOCAL app.tenant_id`, `fetch_all/fetch_one/execute`
src/eai/migrate.py applies sql/postgres/V###__name.sql in order into schema `eai`, table eai.schema_migrations(version, checksum)
src/eai/auth.py Principal(id, kind, tenant_id, roles, scopes, clearance, project, environment); dependency `current_principal`; `require_role(*roles)`, `require_scope(scope)`
src/eai/errors.py ApiError(code, status, message_en, message_fr, details); handler producing the error envelope
src/eai/i18n.py t(key, lang, **params) with en/fr catalogs in src/eai/locales/{en,fr}.json
src/eai/audit.py audit(conn, principal, action, resource_type, resource_id, decision, outcome, details=None) -> hash chained row
src/eai/policy.py evaluate(principal, action, resource, context) -> Decision(allow, reasons, obligations, decision_id); records the decision
src/eai/pagination.py page(rows, limit, key) helpers
src/eai/domains/<name>/ __init__.py exposes `router` (APIRouter, prefix already /v1/...), plus service/repo modules
sql/postgres/V###__*.sql repo root level, idempotent-safe, every tenant table has tenant_id uuid NOT NULL + RLS policy using current_setting('app.tenant_id', true)::uuid
tests/ conftest.py gives fixtures: pg (real PostgreSQL via pgserver, migrations applied once), client (FastAPI TestClient), make_tenant(slug) -> Tenant(id, slug, headers(role=...)), admin_headers
Migration number ranges (no collisions between contributors): core V001-V019, LLM V020-V029, FinOps V030-V039, agents/tools V040-V059, data/multimodal/quality/risk V060-V089.
PostgreSQL here has no pgcrypto: use gen_random_uuid() and sha256() natively. Tests run on a real PostgreSQL (pgserver 16), never mocks for the database.
External systems (LiteLLM, vLLM, OpenBao, OpenFGA, OPA, Keycloak, Qdrant, NATS, Temporal, LiveKit, Opik, ClickHouse) are reached through small adapter interfaces in eai/integrations/<system>.py with an in-process fallback implementation used when the system URL is not configured (so everything is testable here), and a real HTTP implementation used in production.