Skip to content

Tenants & gateways API

Tenants are the top-level organisational unit. Each tenant contains one or more gateways. A gateway holds a configuration object, a set of provider keys, routing rules, and auth tokens.

Base URL: https://<your-gateway-host>/admin/v1


Endpoints

Method Path Description
GET /tenants List all tenants
GET /tenants/{id} Get a single tenant
POST /tenants Create a tenant (platform admin only)
PATCH /tenants/{id} Update a tenant
DELETE /tenants/{id} Soft-delete (decommission) a tenant — disables all auth + routing for it; precedes purge. Platform admin (any tenant) or an org admin of their own tenant; requires a {"confirm":"<slug>"} token
POST /tenants/{id}/restore Restore (undo) a soft-deleted tenant — clears deleted_at, resumes routing (platform-admin only; 409 on a purged tenant)
DELETE /tenants/{id}/purge Irreversibly hard-delete ALL tenant data (platform-admin only, confirmation required)
GET /tenants/{id}/export Exit data export: full tenant handover as a JSON+CSV ZIP (?format=json for the manifest only)
GET /tenants/{id}/deletion-protocol Machine-readable deletion protocol (Löschprotokoll) for a purged tenant — counts-only, platform-admin only
GET /tenants/{id}/analytics Per-tenant timeseries plus top-models for the analytics detail panel
GET /tenants/{id}/spend Tenant spend history across periods
DELETE /tenants/{id}/budget Reset the tenant spend counter (optional ?period= filter) — platform-admin only, audited
GET /tenants/{id}/keys List tenant-scoped provider keys
POST /tenants/{id}/keys Store a tenant-scoped provider key
DELETE /tenants/{id}/keys/{provider}/{alias} Delete a tenant-scoped provider key
GET /tenants/{id}/sso-config Get the tenant's OIDC SSO config (never returns the secret)
PUT /tenants/{id}/sso-config Create/update the OIDC SSO config (tenant_admin)
DELETE /tenants/{id}/sso-config Remove the OIDC SSO config (tenant_admin)
GET /tenants/{id}/saml-config Get the tenant's SAML SSO config (the IdP signing cert is public — returned in full)
PUT /tenants/{id}/saml-config Create/update the SAML SSO config (tenant_admin)
DELETE /tenants/{id}/saml-config Remove the SAML SSO config (tenant_admin)
GET /tenants/{id}/scim-credential SCIM credential status (configured, base URL) — never the token
POST /tenants/{id}/scim-credential Generate/rotate the SCIM bearer (returns the plaintext ONCE; tenant_admin)
DELETE /tenants/{id}/scim-credential Revoke the SCIM credential (tenant_admin)
GET /tenants/{id}/gateways List gateways for a tenant
POST /tenants/{id}/gateways Create a gateway — or update one: this is a true upsert, and posting an existing slug updates that gateway rather than creating a second one (send create_only: true to refuse an existing slug with 409 slug_taken instead)
GET /gateways/{id} Get a single gateway
PATCH /gateways/{id} Update gateway config
DELETE /gateways/{id} Delete a gateway
GET /gateways/{id}/spend Gateway spend history across periods
GET /gateways/{id}/budget Gateway budget status: cap + authoritative current-period spend + an approximate per-model breakdown
DELETE /gateways/{id}/budget Reset the gateway spend counter (optional ?period= filter) — platform-admin only, audited
GET /gateways/{id}/keys List gateway-scoped provider keys
POST /gateways/{id}/keys Store a gateway-scoped provider key
DELETE /gateways/{id}/keys/{provider}/{alias} Delete a gateway-scoped provider key
GET /managed-models/catalog List the Myra-provided (keyless) models that may be enabled
GET /gateways/{id}/managed-models List a gateway's enabled Myra-provided models
POST /gateways/{id}/managed-models Enable a Myra-provided (keyless) model on a gateway
DELETE /gateways/{id}/managed-models/{provider}/{model} Disable a Myra-provided model
GET /gateways/{id}/local-models List the Myra-hosted local fleet with per-gateway enable state
POST /gateways/{id}/local-model-blocks Disable (hard-block) a Myra-hosted local model on a gateway
DELETE /gateways/{id}/local-model-blocks/{provider}/{model} Re-enable a Myra-hosted local model
POST /providers/test-base-url Pre-save reachability check for a provider base-URL override (SSRF-guarded dial; GATEWAYS_MANAGE; rate-limited + audited)

POST /gateways/{id}/managed-models first rejects any provider/model outside the server-side supported allowlist with 400. On a deployment where the platform model pool is not configured (the operator has not wired the managed Anthropic pool), it then refuses with 503 and persists nothing — a grant it accepted could never route, so it is never created rather than silently offered and then failing at request time. Where the pool is configured (the standard hosted deployments), a supported model is enabled and returns 201.

Gateway tokens, routing rules, guardrails, and traces have their own pages: see Users and tokens, Routing rules, Guardrails, and Traces.

💡 config must be a JSON object. On POST /tenants/{id}/gateways and PATCH /gateways/{id}, the config field — when present — must be a JSON object. A scalar (number/string/boolean) or an array is rejected with 400 Bad Request and nothing is persisted. This guarantees a gateway's stored config can never become a null/scalar/array value that would break every later read of the gateway.

An omitted config never destroys one. POST to an existing slug is an upsert, so both routes treat an absent field and an explicit JSON null the same way — leave the stored config exactly as it is:

config in the body existing gateway new gateway
omitted untouched {}
null untouched {}
{} replaced with {} — the explicit wipe {}
{ … } replaced on POST, shallow-merged on PATCH stored
scalar or array 400, nothing persisted 400

Emptying a gateway's configuration is therefore something a caller has to say ("config": {}), not something that happens because a field was left out. This matters most for a client that "patches by re-posting" a partial body such as {"slug": "main", "purpose": "archived"}: previously that request silently discarded the gateway's whole guardrail list — every PII detector included — and still answered 201.

Validated config fields (rejected → 400). These config leaves are validated at the write boundary; a bad value is rejected and nothing is persisted:

config field Accepted Rejected → 400
circuit_breaker.failure_status_codes a JSON array of HTTP status codes (incl. the empty array [] = never trip); absent = default codes a non-array, or any non-integer / out-of-range element
ip_allowlist a JSON array of CIDR strings — IPv4/IPv6, bare address or address/prefix (e.g. 10.0.0.0/8, 2001:db8::/32, 192.168.1.1); []/null/absent = no IP restriction a non-array (object/scalar), a non-string element, or any element that is not a valid CIDR
guardrails[].url / detectors[].url a guardrail detector with no url field (or url: null); the classifier/analyzer endpoints are platform-managed any guardrail detector — under either the guardrails or the legacy detectors key — that carries a non-null url. A tenant-set detector endpoint was a server-side request-forgery (SSRF) vector, so it is rejected outright

The IP allowlist filters inference requests only (a request whose client IP matches no entry is rejected 403); it does not restrict the admin API. See IP allowlist.

🔒 Free-trial gateways are locked (self_serve_trial). A free-trial workspace is provisioned with two server-fixed gateways — internal (Myra fleet) and external (Anthropic Haiku only) — with pre-set per-gateway budgets and provider allowlists (see Budgets). The external gateway (the only one that egresses to a third-party model) is also provisioned with a default pii-protect PII-masking guardrail (a pii_protector detector, enabled), so personal data is masked before it leaves for the provider from day one; internal gets no default guardrail. This guardrail is not part of the trial lock below — a tenant admin can edit, toggle, or remove it on the gateway's Guardrails page during the trial and after conversion (see Guardrails). As with any PII-active gateway, a native /v1 request to external that carries inline base64 media (image/pdf/audio) is fail-closed rejected (pii_media_unmaskable), since PII inside binary cannot be scanned before it egresses; the workspace chat (server-side text extraction) is unaffected. While the tenant is on the self_serve_trial plan the gateway write boundary is locked server-side so the split cannot be collapsed onto real vendor spend: - PATCH /gateways/{id} that touches budget_usd, budget_period, provider_allowlist, or provider_allowlist_enforced → 403 Forbidden (the value is not persisted). An edit that touches none of these (e.g. guardrails) still succeeds. The lock keys on the presence of a locked field, so a null/garbage value is rejected exactly like a real one. - POST /tenants/{id}/gateways → 403 Forbidden for the whole path (creating or replacing a gateway is disallowed; a trial's topology is fixed). - DELETE /gateways/{id} → 403 Forbidden. - POST /gateways/{id}/guardrail-config/import → 403 Forbidden if the import would change provider_allowlist or provider_allowlist_enforced (this declarative-replace path can also strip them). A guardrails-only import that leaves those keys unchanged still succeeds. - Applying a governance template (POST /governance/templates/{id}/apply) that changes a locked control (provider_allowlist_enforced) → 403 Forbidden. - DELETE /gateways/{id}/budget and DELETE /tenants/{id}/budget (spend-counter resets) → 403 Forbidden — but not because of the trial — these two resets are platform-admin only for every plan (a tenant-class actor may never zero the ledger, which would re-open the per-gateway cap and the platform_cap_usd ceiling). The 403 therefore does not lift on upgrade for a tenant admin; only a platform operator can reset spend. See Resetting the tenant/gateway budget counter.

The plan is read from the authoritative tenant record (never the request); a transient tenant-lookup failure returns 503 (retryable), never a silent bypass. Normal editing resumes automatically once the workspace upgrades to a paid plan.


Tenants

Listing tenants

curl https://<your-gateway-host>/admin/v1/tenants

Response:

[
  {
    "id": "ten_abc123",
    "slug": "myapp",
    "plan": "starter",
    "budget_usd": null,
    "budget_period": "monthly",
    "admin_count": 2,
    "effective_seat_cap": null,
    "effective_spend_cap": null,
    "created_at": 1742551200
  }
]

Any authenticated member of a tenant may list it (the tenant selector on member surfaces relies on this). The response is role-scoped: editor-only configuration is disclosed only to a caller who can edit the tenant (platform admin or tenant_admin). A plain member / ki_manager receives a redacted row that omits the SIEM ingest configuration (siem — which carries the Splunk HEC token / Basic credentials), the spend budget (budget_usd, budget_period), the platform-admin-set budget ceiling (platform_cap_usd, same tier as budget_usd), the DEMO/trial end (trial_ends_at), the code-interpreter artifact cap (code_interpreter_artifact_max_bytes), the plan tier, the org-wide system_instruction, prompt_examples (which carries each example's steering system_prompt), the admin_count, and the effective caps (effective_seat_cap, effective_spend_cap — same sensitivity tier as budget_usd/plan they are derived from). These fields are never sent to a non-editor.

admin_count is a read-only, server-computed count of the tenant's active administrators — users whose role is admin or tenant_admin, excluding soft-deleted and disabled accounts (the same predicate the last-administrator delete/disable guard enforces). It powers the "Organisation admins" column in the admin UI. A tenant with no live administrators returns 0 (never blank). It is present on this list endpoint only — GET /admin/v1/tenants/{id} (the single-tenant read) does not include it. If the count sub-query faults, the field is returned as null (the whole list still succeeds — the count degrades to "unknown" rather than failing the request), so a client distinguishes a genuine 0 from an unavailable count. It is redacted for non-editor members.

effective_seat_cap / effective_spend_cap are read-only, server-computed fields that show the tenant's effective caps so an operator can tell an uncapped tenant from a capped one without reading the DB. Both are derived from the single resolver each cap already has (the seat cap from the same resolver user provisioning enforces; the spend cap from the one formula the metering pipeline enforces — base budget plus any active time-bounded voucher bonus), never re-computed. Each is either a number (the cap; a 0 spend cap is a platform freeze), null (explicitly unlimited), or absent — for effective_seat_cap, absent means the resolver faulted and the value is unknown (the client renders —, never "unlimited": fail closed). They are present on both this list endpoint and GET /admin/v1/tenants/{id} (recomputed each read, so a value refreshes after a PATCH that changes budget/plan — unlike admin_count, which is list-only), are computed only for an editor (a non-editor member skips the work), and are redacted for non-editor members. A companion worker-0 monitor (admin/uncapped_tenant_watcher) fires a [platform_alert] once when a non-self-serve tenant with no seat cap crosses a user-count threshold — see docs/internal/tenant-cap-classification.md.

Getting a tenant

curl https://<your-gateway-host>/admin/v1/tenants/{id}

Returns the single tenant record. The caller must have access to the tenant (require_tenant_access); otherwise the request is rejected. Returns 404 { "error": "tenant not found" } if no such tenant exists. The same role-scoped projection as the list applies: a non-editor (member / ki_manager) never receives siem, budget_usd, budget_period, platform_cap_usd, trial_ends_at, code_interpreter_artifact_max_bytes, plan, system_instruction, prompt_examples, or the effective caps (effective_seat_cap, effective_spend_cap) — only a platform admin or the tenant's tenant_admin sees the full configuration. (The effective caps ARE emitted on this single-tenant read for an editor — see the note above — unlike admin_count.)

Editor-only enrichment. For an editor, the response also carries the white-label text branding (brand_product_name, brand_home_greeting, brand_chat_disclaimer, brand_sidebar_links) plus the decoded prompt_examples — these live off the tenant list payload (kept off the inference hot path) and are read here via a separate branding query. if that branding sub-read fails (a transient DB fault, or the tenant is deleted between the two reads), the response is still 200 for the rest of the record but the four branding-text fields are absent and a flag "branding_unavailable": true is set. A client MUST treat that as "branding not loaded" (show the fields read-only, and OMIT them from the next PATCH) rather than as an unbranded tenant — sending them back as null would wipe the stored columns. On a successful read the flag is absent.

Creating a tenant

Only a platform admin may create a tenant. Any other role receives 403 { "error": "forbidden" }.

curl -X POST https://<your-gateway-host>/admin/v1/tenants \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "myapp",
    "plan": "starter",
    "budget_usd": null
  }'
Field Type Required Description
slug string Yes URL-safe identifier. Must be unique. Used in inference endpoint URLs. creating a tenant with a slug that already exists is rejected 409 { "error": "an organisation with this name already exists", "code": "slug_taken" } — nothing is created and the existing organisation is left untouched. (Previously this silently returned the existing org's id with 201, creating nothing and dropping the submitted settings.) A genuinely new slug returns 201 { "id", "slug" }. Uniqueness is global (it also covers a soft-deleted organisation still holding the name — purge it to free the slug).
plan string No Arbitrary plan label (e.g. starter, pro). Not enforced by the gateway. Platform admin only. POST is platform-admin-only for the whole route. On PATCH, a non-platform-admin request that supplies plan with a value different from the stored one is rejected 403 naming the field and writes nothing; omitting it — or re-sending the unchanged value — is a 200 no-op.
budget_usd number | null No Tenant-level spend cap in USD. A platform admin's change always applies immediately. A tenant_admin's genuine change (a real value delta — not an omit or an identical resubmit) is no longer hard-403'd — it is PERMITTED, and whether it is routed through the four-eyes config-approval gate now depends on the tenant's own budget_four_eyes_required opt-in (below, default OFF). When budget_four_eyes_required is OFF (the default): the change applies immediately (200) and is audited (tenant.budget_changed, before/after) — no approval queue. When it is ON: the change is captured behind the gate — the response is 202 { "status": "pending_approval", "approval_id": "..." } and nothing is written until a platform admin approves it (see Config approvals — entity type tenant). This opt-in is independent of the tenant's general config_approval_required toggle. Regardless of the opt-in, a tenant_admin's request is first validated against platform_cap_usd (below) — a request exceeding the cap, or a clear to unlimited (null) under a cap, is rejected 400 before it applies or reaches the approval queue (the cap ceiling is bound to the change, not to the four-eyes toggle, so turning the gate off can never relax it); a held change is re-validated again at approval time against the (possibly since-lowered) cap. Omitting the field, or re-sending the identical stored value, stays a plain 200 no-op (never gated, never audited). A wrong type (non-number, non-null) is rejected 400. the value must be >= 0 — a negative budget (or NaN/+inf) is rejected 400 ("budget_usd must be zero or greater") at the create route and every PATCH path (including the four-eyes queue-time check and its re-validation at approval). 0 is a legitimate hard-freeze (blocks all spend) and null is unlimited; only negatives are refused. Rationale: a negative budget makes the quota cap negative, so the tenant is blocked from its very first request. Note: with the opt-in OFF and no platform_cap_usd set, a tenant_admin's budget change (including clearing to unlimited) applies immediately and unqueued — operators who want a ceiling set platform_cap_usd; the immediate change is still audited. Enterprise wallet funding (AGF-2845): for a tenant on the enterprise plan, an increase to budget_usd also writes an admin-grant credit of the delta (new − old, old null/unset treated as 0) to that tenant's prepaid wallet — the balance the managed Your plan tile / Wallet tab display (billing overview). The credit fires only when the change actually applies — on immediate apply, or on four-eyes approval (config approvals), never at submission of a held change — and is idempotent (a re-apply / replayed approval never double-credits). A decrease credits nothing and never debits (an over-funded wallet is never clawed back); a change to null (unlimited) credits nothing. Self-serve plans are unaffected (they fund the wallet through Stripe top-up); the credit is gated to enterprise only. The wallet_ledger row carries the granting admin's id (granted_by) under the admin_grant kind with NULL Stripe fields.
platform_cap_usd number | null No PATCH only, platform admin only. The budget ceiling a tenant_admin's own budget_usd request may never exceed. null/absent = no ceiling (any budget_usd value is accepted from a tenant_admin). The ceiling is enforced on every genuine tenant_admin budget change regardless of budget_four_eyes_required — it is never relaxed by turning the four-eyes gate off. Same rule as plan/budget_period: a non-platform-admin PATCH that changes it is rejected 403; omit or re-send the stored value → 200 no-op. must be >= 0 — a negative/NaN/+inf cap is rejected 400 ("platform_cap_usd must be zero or greater"); a negative cap would otherwise reject every tenant_admin budget edit. Settable only via this field — a tenant admin sees it read-only in User Management › Organisation › Edit › General (formerly the retired "Workspace settings" popup).
budget_four_eyes_required number (0|1) No PATCH only, platform admin only. Default 0 (OFF). The per-tenant opt-in that routes a tenant_admin's budget_usd change through the four-eyes config-approval gate. OFF (default): a genuine tenant_admin budget change applies immediately (200, audited) — subject to platform_cap_usd. ON: the change is held (202 pending_approval) until a second/platform admin approves it. It is a governance control imposed on the tenant (same tier as platform_cap_usd), so a non-platform-admin PATCH that changes it is rejected 403 { "error": "budget_four_eyes_required is platform-admin-only and cannot be changed by a tenant admin" } and writes nothing — the 403 returns before the budget gate, so a tenant_admin can neither arm nor disarm it (and a bundled {budget_usd, budget_four_eyes_required: 0} change to escape an armed gate is rejected here, before the gate reads the stored flag). Omitting it, or re-sending the unchanged value, is a 200 no-op. A wrong type (e.g. "banana") from a platform admin is a tonumber no-op (the flag is never turned on by garbage); any number other than 1 coerces to 0. A change is audited (tenant.budget_four_eyes_changed, before/after). Settable in User Management › Organisation › Edit › General (platform admin only).
budget_currency string No PATCH only, platform admin only. The currency the organisation budget is entered and displayed in: "USD" (default) or "EUR". Case-insensitive and trimmed (" eur " → EUR). This is an ENFORCEMENT-facing setting and is entirely separate from the per-user display preference (user.currency) — one is an organisation's chosen unit, the other is a personal viewing preference. How it works: budget_usd keeps holding genuine USD and remains the only figure the metering pipeline enforces. When you set a EUR budget the gateway converts it once, server-side, at a freshly-fetched USD/EUR reference rate, stores the resulting USD figure, and pins that rate on the tenant (budget_fx_rate). The euro amount shown back to you is re-derived from the pinned rate, so it is stable: your cap does not move when the exchange rate moves. Rejected → 400: any code other than USD/EUR (e.g. "GBP" — we have no validated reference rate for it), an empty/whitespace string, a non-string (number, boolean, array, object), and JSON null (the column is NOT NULL; "unlimited" is expressed by budget_usd: null, never by clearing the currency). Also rejected 400 on a tenant whose plan is a self-serve tier, or on a request that moves the tenant onto one — a self-serve allowance is written by the platform in USD, so a non-USD tag there would display an amount nobody chose. Rejected → 503 if no reference rate can be obtained at that moment: a pinned rate is permanent, so the gateway refuses rather than freezing a guessed one — retry shortly. A non-platform-admin PATCH that changes it is rejected 403; omitting it or re-sending the stored value is a 200 no-op. Not accepted on POST (create the organisation first, then set its currency). Every change is audited (tenant.budget_currency_changed, carrying the before/after currency, rate and amount).
budget_amount number | null No The organisation budget expressed in budget_currency — the figure you actually type. Sent instead of budget_usd; the server does the conversion, so no client ever computes a money figure. Interpreted in the currency resulting from this request: a single PATCH carrying {"budget_currency": "EUR", "budget_amount": 100} means €100, not $100. On a USD tenant it is simply the identity, and no exchange rate is consulted at all. Returned on GET alongside budget_usd so a full-object round-trip works; it is omitted when the budget is unlimited, and also omitted (with a server-side error log) if the stored currency or pinned rate is unreadable — a budget figure is never guessed. Subject to the same authz and the same guards as budget_usd, because it is normalised into it before any gate runs: a tenant_admin's genuine change returns 202 pending_approval through the four-eyes gate, is validated against platform_cap_usd, and must be >= 0 both as typed and after conversion (400 otherwise — a negative cap would block the organisation from its very first request). Sending budget_usd and budget_amount as two conflicting changes is rejected 400; re-sending both unchanged (what a GET→PATCH round-trip does) stays a 200 no-op. Not accepted on POST.
budget_fx_rate / budget_fx_pinned_at number | null No Read-only (set implicitly when budget_currency is set; never accepted in a request body). The EUR-per-USD reference rate pinned when the currency was chosen, and when it was pinned (unix seconds). null on a USD organisation — there is nothing to convert, so a USD organisation has no exchange-rate dependency at all. Re-pinned only when the currency itself changes: an ordinary budget edit reuses the stored rate, so editing (or freezing) a budget never depends on the reference-rate provider being reachable. Both are included in the tenant data export as the provenance of the stored budget.
budget_period string No Reset period for the spend counter: "daily", "monthly", or "total". Default: "monthly". Platform admin only (same PATCH rule as plan: a non-platform-admin change → 403; omit or unchanged → 200 no-op).
trial_ends_at number | null No DEMO/trial phase end — absolute unix timestamp in SECONDS. A tenant with this set is on a trial: once the current time reaches it, the gateway blocks all inference for the tenant with 402 { "error": { "code": "trial_expired" } } (the /admin/* surfaces stay reachable so the account can be upgraded). null/absent = not on a trial. The trial BUDGET is the existing budget_usd cap (set budget_period: "total" for a one-off lifetime trial cap). An active or in-grace self-serve subscription supersedes a still-set (stale) trial_ends_at — a converted DEMO tenant is never hard-blocked trial_expired while paying. The supersede is narrow: only a served subscription (active or grace) skips the trial gate. A tenant that is inactive, or carries a self-serve plan label with no subscription, is still trial_expired if the demo end-date has passed (fail-closed). Platform admin only (same PATCH/POST rule as plan/budget_usd). A set / extend / clear is audited (tenant.trial_changed, with the before/after epoch) — it gates all inference for the tenant, so the who/when is traceable; a no-op re-save is not audited. Accepted: a whole positive unix-seconds integer within (now, now+100y] plus past values (a past epoch = an already-ended/revoked trial), or null to clear. Rejected → 400: a non-integer, a negative or zero number, a milliseconds magnitude (> now+100y), or an ISO-8601 / hex / non-decimal string.
request_logging_disabled number (0|1) No PATCH only, platform admin only. 1 turns off request logging for this tenant: the gateway writes no request_log/request_log_legs rows for its traffic and the tenant's Request Logs view stays empty. 0 (default) logs normally. A tenant admin's value is ignored (silently); any value other than 0 or 1 is ignored. Billing, quota enforcement, and separately-configured SIEM/webhook forwarding are unaffected — this governs the request-log store only.
compliance_report_enabled number (0|1) No PATCH only. Platform admin only. Enables the scheduled compliance-report snapshot job for this tenant (see Compliance API): 1 opts the tenant into the worker-0 sweep minting its monthly governance snapshots; 0 (default) leaves it off. Enablement is per-tenant only — there is no deployment-wide switch. The snapshot content is DPO-gated governance evidence, so this is not tenant self-service: a non-platform-admin request that changes the flag is rejected 403 naming the field and writes nothing (omit or re-send the unchanged value → 200 no-op). A change is audited (tenant.compliance_report_enabled_changed, before/after) only when the value actually flips; a non-numeric value is a tonumber no-op and any number other than 1 coerces to 0.
reactivation_emails_enabled number (0|1) | null No PATCH only. Tenant admin or platform admin. The per-tenant switch for the member-reactivation e-mails. Tri-state: 1 = on, 0 = off, null = Auto (on for an enterprise plan or an unexpired trial with at least one purpose=production gateway and not a test fixture; off otherwise). Stored default by plan (details): a self_serve_trial tenant is null (Auto, so on while its trial is unexpired — migration 0342, CEO decision 2026-09-29) unless a tenant admin opted out; enterprise tenants (POCs included) were set to 0 by migration 0337 and stay off until enabled per tenant. A mail is sent only when this resolves to on and the platform setting reactivation_emails_enabled is true. Accepted: JSON null, or a JSON number 0 or 1. Rejected → 400 { "error": "reactivation_emails_enabled must be null, 0 or 1" } and nothing written: a string ("1"), a boolean, any other number, an array/object. Not four-eyes gated. A change is audited (tenant.reactivation_emails_enabled_changed, before/after; the unset state is written as "auto"); re-sending the stored value is a 200 no-op. GET /tenants/:id additionally returns reactivation_emails_effective (0|1, editors only) — what the tri-state resolves to right now.
default_locale string ("en"|"de") | null No PATCH only. Tenant admin or platform admin. The workspace's default e-mail language — the second link of the one lifecycle-mail language chain (member's own locale → this → English) used by the sign-in code, the invitation and the member-reactivation e-mails. Accepted: JSON null (clear → "not chosen": English until the next same-tenant invitation seeds it again from the inviter's interface language), or a string naming a supported language — stored normalized ("DE", " de-DE " → "de"). Rejected → 400 { "error": "default_locale must be null, \"en\" or \"de\"" } and nothing written: any other language ("fr"), an empty or blank string, a number, a boolean, an array/object, a dangling tag ("en-"). Not four-eyes gated. A change is audited (tenant.default_locale_changed, before/after; the unset state is written as "auto"); re-sending the stored value is a 200 no-op. Returned by GET /tenants/:id as stored (null when not chosen).
default_user_type string No Platform admin only. The chat-persona slug ("Default role for new users") every NEW user of the tenant is provisioned with — a governance decision, so it is not tenant self-service. Accepted: a slug that exists in the live persona registry (GET /user-types, e.g. consumer (the default), bundestag). accepted on POST (create) too — the chosen persona is persisted at creation (it was previously silently dropped, defaulting every new tenant to consumer); omitting it or sending JSON null on create → the consumer default. On PATCH, a non-platform-admin request that changes it is rejected 403 { "error": "default_user_type is platform-admin-only and cannot be changed by a tenant admin" } and writes nothing (same rule as plan); omitting it or re-sending the stored value is a 200 no-op. On both POST and PATCH from a platform admin, an unknown/unregistered slug is rejected 400 { "error": "unknown default_user_type", "allowed": [...] } (nothing created/written). (Briefly a tenant-admin four-eyes-gateable field; that was later retired — a tenant_admin can no longer change it, immediately or via approval.)
assistant_name string | null No The per-tenant white-label name the model's assistant answers as (up to 80 characters; leading/trailing whitespace trimmed). accepted on POST (create) AND PATCH — the chosen name is persisted at creation (it was previously silently dropped, so a new tenant always showed the platform default). Omitting it, or sending JSON null / an empty-or-whitespace-only string, → null = the platform default name. A non-string value → 400 { "error": "assistant_name must be a string or null" }; a value over 80 characters → 400 { "error": "assistant_name must be at most 80 characters" } — on both routes, nothing created/written.
playground_enabled number (0|1) No PATCH only, platform admin only. A feature ENTITLEMENT — which capabilities a workspace has been granted — so it is a platform-operator decision, not tenant self-service. A non-platform-admin PATCH that changes it is rejected 403 { "error": "<field> is platform-admin-only and cannot be changed by a tenant admin" } and writes nothing; the 403 returns before the four-eyes gate, so a tenant admin cannot have the change merely approved by a second tenant admin either. Omitting the field, or re-sending the unchanged stored value, stays a valid 200 no-op (idempotent full-object round-trips keep working). A wrong type (e.g. the string "0") from a non-platform-admin counts as a change attempt and is rejected 403 rather than coerced — a platform admin's tonumber coercion is unchanged. The per-tenant Playground feature flag. Default 0 for every newly-created tenant (migration 0293 — Playground is a premium / not-yet-public feature, so it is off for trial, admin-created and paid signups alike; a platform admin enables it per-tenant afterwards; existing tenant rows were left unchanged). 1 = Playground available; 0 marks it a Premium feature in the SPA — the nav entry stays visible with a Premium badge and a click opens the shared upgrade pop-up, rather than being hidden (a not-entitled deep-link to the route still bounces to chat) — and enforces the switch server-side (invariant 11 — the nav is never the authz boundary): the playground-only GET /admin/v1/playground/search returns 403 { "error": "feature_disabled" } (a transient flag-read fault → 503). The single-trace read GET /admin/v1/playground/trace/{id} is REQUEST_LOGS_VIEW-gated, not flag-gated. Exempt: POST /admin/v1/playground/token — chat AND the Playground UI mint their short-lived inference token via this one route (there is no separate chat_enabled flag), so gating it would break ordinary chat with feature_disabled; minting depends only on gateway access + the impersonation guard. Unlike agents_enabled/workflows_enabled/scheduled_tasks_enabled, a change here is not audited (matches org_share_enabled). A non-numeric value is a tonumber no-op; any number other than 1 coerces to 0.
agents_enabled number (0|1) No PATCH only, platform admin only. A feature ENTITLEMENT — which capabilities a workspace has been granted — so it is a platform-operator decision, not tenant self-service. A non-platform-admin PATCH that changes it is rejected 403 { "error": "<field> is platform-admin-only and cannot be changed by a tenant admin" } and writes nothing; the 403 returns before the four-eyes gate, so a tenant admin cannot have the change merely approved by a second tenant admin either. Omitting the field, or re-sending the unchanged stored value, stays a valid 200 no-op (idempotent full-object round-trips keep working). A wrong type (e.g. the string "0") from a non-platform-admin counts as a change attempt and is rejected 403 rather than coerced — a platform admin's tonumber coercion is unchanged. The per-tenant Agents feature flag. Default 0 for every newly-created tenant (migration 0293 — Agents is a premium / not-yet-public feature, so it is off for trial, admin-created and paid signups alike; a platform admin enables it per-tenant afterwards; existing tenant rows were left unchanged). 1 = Agents available; 0 keeps the Agents nav entry visible but LOCKED — an upgrade/"Coming soon" teaser badge, and a click lands on the /upgrade/agents upsell page instead of the workspace (replaces a legacy behavior of hiding the nav entry entirely) — and enforces the switch server-side (invariant 11 — the nav is never the authz boundary): every /admin/v1/gateways/{gw}/agents… route and the tenant's org agent-catalog return 403 { "error": "feature_disabled" }, a human agent invoke (/v1/{tenant}/{gw}/agents/{slug}/invoke from a user session/token, the run-as-self preview, and the in-chat agent bridge) returns 403 { "error": { "code": "agents_disabled" } }, the inbound agent-webhook receiver returns 503 (no run enqueued), and the scheduler withholds the tenant's agent schedules + pending webhook runs at the claim layer (no fire, no failure email, no auto-pause — they resume when re-enabled). Exempt (still reachable at 0): the governance review decisions (approve/flag/disable/reactivate) on already-created agents, the gateway compliance audit read, and agent-steps that run inside an enabled Workflow (governed by workflows_enabled). A change is audited (tenant.agents_enabled_changed, before/after) only when the value actually flips. A non-numeric value is a tonumber no-op; any number other than 1 coerces to 0.
workflows_enabled number (0|1) No PATCH only, platform admin only. A feature ENTITLEMENT — which capabilities a workspace has been granted — so it is a platform-operator decision, not tenant self-service. A non-platform-admin PATCH that changes it is rejected 403 { "error": "<field> is platform-admin-only and cannot be changed by a tenant admin" } and writes nothing; the 403 returns before the four-eyes gate, so a tenant admin cannot have the change merely approved by a second tenant admin either. Omitting the field, or re-sending the unchanged stored value, stays a valid 200 no-op (idempotent full-object round-trips keep working). A wrong type (e.g. the string "0") from a non-platform-admin counts as a change attempt and is rejected 403 rather than coerced — a platform admin's tonumber coercion is unchanged. The per-tenant Workflows (Baukasten) feature flag. 0 (default, fail-closed) keeps the Workflows nav entry visible but LOCKED — an upgrade/"Coming soon" teaser badge, and a click lands on the /upgrade/workflows upsell page instead of the builder (replaces a legacy behavior of hiding the nav entry entirely) — and blocks the builder + its /admin/v1/gateways/{gw}/workflows… routes for this workspace; 1 enables it. The dedicated POST /admin/v1/gateways/{id}/workflows-feature route writes the SAME column and is platform-admin-only for the same reason — a tenant admin gets 403 there too, so the guard above cannot be side-stepped. Shares one write with the dedicated POST /admin/v1/gateways/{gw}/workflows-feature endpoint (identical platform-admin authz). A change is audited (tenant.workflows_enabled_changed, before/after) only when the value actually flips. A non-numeric value is a tonumber no-op; any number other than 1 coerces to 0.
scheduled_tasks_enabled number (0|1) No PATCH only, platform admin only. A feature ENTITLEMENT — which capabilities a workspace has been granted — so it is a platform-operator decision, not tenant self-service. A non-platform-admin PATCH that changes it is rejected 403 { "error": "<field> is platform-admin-only and cannot be changed by a tenant admin" } and writes nothing; the 403 returns before the four-eyes gate, so a tenant admin cannot have the change merely approved by a second tenant admin either. Omitting the field, or re-sending the unchanged stored value, stays a valid 200 no-op (idempotent full-object round-trips keep working). A wrong type (e.g. the string "0") from a non-platform-admin counts as a change attempt and is rejected 403 rather than coerced — a platform admin's tonumber coercion is unchanged. The per-tenant Scheduled Tasks feature flag. Default 0 for every newly-created tenant (migration 0293 — Scheduled Tasks is a premium / not-yet-public feature, so it is off for trial, admin-created and paid signups alike; a platform admin enables it per-tenant afterwards; existing tenant rows were left unchanged). 1 = Scheduled Tasks available; 0 keeps the Scheduled Tasks nav entry visible but LOCKED — an upgrade/"Coming soon" teaser badge, and a click lands on the /upgrade/scheduled-tasks upsell page instead of the workspace (replaces a legacy behavior of hiding the nav entry entirely) — and enforces the switch server-side (invariant 11 — the nav is never the authz boundary): every /admin/v1/scheduled-tasks… route returns 403 { "error": "feature_disabled" } (a transient flag-read fault → 503), and the scheduler withholds the tenant's scheduled prompt-tasks at the claim layer (no fire, no email; next_run_at untouched so they resume when re-enabled). A change is audited (tenant.scheduled_tasks_enabled_changed, before/after) only when the value actually flips. A non-numeric value is a tonumber no-op; any number other than 1 coerces to 0.
pii_internal_delivery_enabled number (0|1) No PATCH only. Tenant-admin settable (for the caller's OWN tenant) — unlike the platform-only flags. Opts the tenant in to delivering a scheduled/workflow agent run's UNMASKED output by email to a tenant-internal recipient when the run masked PII, instead of the blanket fail-closed block. Default 1 (ON) since AGF-3098 / migration 0358 (reverses 0218's default-0; the migration also backfilled existing active tenants to 1), and a tenant admin can toggle it in Workspace settings (the tenant edit form). Set 0 to go back to blocking every masked run's output. 1 allows delivery only for a recipient that resolves to an active, registered user of the same tenant (storage.find_admin_user_by_email, server-side); an external / disabled-user / other-tenant / non-user address STILL blocks (content-free "delivery blocked" notice). Email only — Mattermost and webhook stay blocked regardless (no per-channel/URL tenant-ownership proof). Because a tenant admin relaxing their own PII egress boundary is a governance event, every change is audited (tenant.pii_internal_delivery_changed). A non-numeric value is a tonumber no-op; any number other than 1 coerces to 0.
mattermost_delivery_enabled number (0|1) No PATCH only, platform admin only. Enables scheduled/workflow Mattermost output delivery for this tenant. Default 1 (ON) since AGF-3087 / migration 0357 (reverses migration 0204's default-0; the migration also backfilled existing active tenants to 1). A platform admin may set 0 to disable it per tenant, in which case the agent scheduler and workflow runner record blocked_tenant. Scheduled Mattermost delivery posts through the tenant's own bot token (mattermost_bot_token, below) — there is no shared internal bot (AGF-3089 removed it; the CEO rejected shared-bot egress). Toggling this flag is a platform-operator decision — a non-platform-admin PATCH that tries to change it is rejected 403 (same rule as plan/budget_usd); omitting it or re-sending the stored value is a 200 no-op; a non-numeric value from a platform admin is ignored (tonumber no-op, flag unchanged), while any other number coerces to 0 (OFF) — so the stored value is 1 unless an admin writes an explicit non-1. Every change is audited (tenant.mattermost_delivery_changed). Applied immediately. Note (post default-on): with the flag ON, egress is scoped by the tenant's own bot's channel membership (its bot posts only to channels it was invited to). Whenever Mattermost cannot go through — no per-tenant token, a token that fails to decrypt, or the Mattermost POST itself failing — the full result is emailed to the schedule/workflow owner instead (the "email the user, never fail" directive), so the flag being ON never silently drops a result. Email/webhook delivery is unaffected (no per-tenant flag).
mattermost_bot_token string | null No PATCH only, platform admin only. WRITE-ONLY. The tenant's own Mattermost bot access token (AGF-3089), so scheduled/agent/workflow delivery posts as the tenant's bot — scoping egress to the channels that bot was invited to and removing the shared-bot cross-tenant reach AGF-3087 opened. Stored encrypted at rest (AES-256, master-key envelope); never returned by GET (the read exposes only the boolean mattermost_bot_token_set), never logged, and audited as a boolean only (tenant.mattermost_bot_token). Accepted: a non-empty opaque string, trimmed, ≤255 chars, no control characters and no internal whitespace → validated then encrypted before any write. Rejected: a non-string value → 400 { "error": "mattermost_bot_token must be a string or null" }; an empty/too-long/whitespace/control-char string → 400 naming the rule. Clears the token (the tenant then has no per-tenant bot) on JSON null or "". A non-platform-admin PATCH that supplies the field at all is rejected 403 { "error": "mattermost_bot_token is platform-admin-only and cannot be changed by a tenant admin" } (write-only, so there is no stored value to diff against — presence alone is the trigger); omitting it is a 200 no-op. Resolution at delivery (never-fail): token present + decryptable → the tenant's bot is used; absent (no token) → the full result is emailed to the schedule/workflow owner instead (there is no shared bot); present but undecryptable → logged server-side (never the token) and also emailed to the owner — explicitly not blocked_tenant, because with no shared bot there is no permissive path to fall into, so blocking would only drop a deliverable result. The only genuinely un-deliverable case (a missing/inactive/cross-tenant owner, or the owner email itself failing) is recorded as failed.
conversation_retention_days number | null No Platform-admin-only. Per-tenant chat-content Löschfrist in days. null/0 = disabled (default); a whole number 30–3650 arms automated deletion of conversations with no activity for that many days. Any other value (fractional, <30, >3650, wrong type) is rejected 400 and writes nothing. Automated deletion must also be enabled deployment-wide by your operator. See Data retention.
request_log_retention_days number | null No Platform-admin-only. Per-tenant request-log Löschfrist in days. null/0 = disabled (default); a whole number 30–3650 arms automated permanent deletion of request_log rows (prompts/responses) older than that many days. Independent of conversation_retention_days. The append-only request_log_legs cost ledger is not deleted. Any other value (fractional, <30, >3650, wrong type) is rejected 400 and writes nothing. Automated deletion must also be enabled deployment-wide by your operator (off by default). Each change is audited; each sweep writes a counts-only Löschprotokoll audit row.
code_interpreter_artifact_max_bytes integer | null No PATCH only, platform admin only. Largest single code-interpreter artifact (in DECODED bytes) this tenant may egress from the sandbox — a spreadsheet, document, image, or data file the model produces. null/absent = the built-in 4 MiB default (every existing tenant is unchanged until opted up). Accepted: a whole integer in [4194304, 26214400] (4 MiB … 25 MiB), or null to reset to the 4 MiB default. Rejected → 400 (nothing persisted): a non-integer, a fractional byte count, 0, a negative number, or any value below 4 MiB or above 25 MiB. The setting only ever raises the ceiling (the 4 MiB floor guarantees it can never make a tenant worse than the default). The same effective cap also bounds the tenant's live-delivered images and written files (they share the per-turn wire budget). The gateway re-reads the stored cap on every egress and clamps to it — the client is never the size authority. A non-platform-admin PATCH that changes it is rejected 403 (same rule as budget_usd); omit or re-send the stored value for a 200 no-op. Every change is audited (tenant.code_interpreter_artifact_cap_changed, before/after bytes).
expected_version integer No PATCH only. Optimistic-concurrency precondition. Must be a non-negative integer — the row_version the caller read from a prior GET. When present, the update is applied only if the tenant's current row_version still equals it; if another request changed the tenant in between, the PATCH is rejected 409 { "error": "stale", "row_version": <current> } and writes nothing. Any other type (float, string, negative, null) is rejected 400. Omitting it keeps the legacy last-writer-wins behaviour (backward-compatible for scripted callers). A successful PATCH bumps row_version and returns the new value (see Response).

Response: { "id": "...", "slug": "..." } on POST. A PATCH returns { "ok": true, "row_version": <new> } — adopt the returned row_version as the expected_version for the next edit so a follow-up save does not falsely 409.

Updating a tenant

curl -X PATCH https://<your-gateway-host>/admin/v1/tenants/{id} \
  -H "Content-Type: application/json" \
  -d '{"plan": "pro", "budget_usd": 1000.00, "budget_period": "monthly"}'

⚠️ plan / budget_period / platform_cap_usd / trial_ends_at / mattermost_delivery_enabled / code_interpreter_artifact_max_bytes / compliance_report_enabled / default_user_type / playground_enabled / agents_enabled / workflows_enabled / scheduled_tasks_enabled / conversation_retention_days / request_log_retention_days / bedrock_region / vertex_region / web_search_provider / siem are platform-admin-only. A PATCH from a non-platform-admin (e.g. a tenant_admin) that tries to change any of them is rejected with 403 { "error": "<field> is platform-admin-only and cannot be changed by a tenant admin" } and the request writes nothing (no partial update, no side effect). A PATCH that omits them (the normal tenant-admin edit shape) or re-sends the current stored value is a valid 200 no-op — so idempotent full-object round-trips and tenant-admin edits of other fields (branding, assistant name, …) keep working. The SPA hides these controls from a tenant admin (defense in depth), but the backend is the authoritative boundary — the rejection applies to every caller (public admin API, mobile, scripts), not just the dashboard.

Data & Retention + Routing: the tenant-admin SPA does not show the Data & Retention tab (conversation_retention_days, request_log_retention_days) or the Routing tab (bedrock_region, vertex_region, web_search_provider), and the API enforces it too: a tenant_admin PATCH that would change any of the five is rejected 403 naming the field and writes nothing. The compare is NULL-normalising per field so an idempotent full-object round-trip keeps working — for the retention pair null and 0 are both "disabled"; for the two regions null and "" are both "unset"; for web_search_provider only null is "unset" ("" is that field's own 400, for every caller). Otherwise the value is compared verbatim: a padded or re-cased echo of the stored value counts as a change (fail closed), and so does a wrong-typed value ("90", 42). An unchanged echo (or an omit) is a 200 no-op for these columns — no re-write, no audit row, no gateway-cache flush (the tenant's row_version still advances, as on every successful PATCH). Scope, stated honestly: this guards the tenant's org-wide default columns. The per-gateway region / web-search override on PATCH /admin/v1/gateways/{id} (GATEWAYS_MANAGE) is a different surface and remains tenant-admin-settable; whether it should be gated too is still an open ruling. The retention pair has no other write door, so that half is a complete boundary.

budget_usd is the one exception: it is NOT in the 403 list above. A tenant_admin's genuine change is permitted, not blocked. Whether it applies immediately (200, audited) or is routed through the four-eyes config-approval gate (202 held) depends on the tenant's per-tenant budget_four_eyes_required opt-in (default OFF → immediate). The platform_cap_usd ceiling is enforced in both cases (see the field's own row above and Config approvals).

🔒 Optimistic concurrency (avoiding lost updates). The tenant edit is a read-modify-write: a client loads the tenant, then saves. Two concurrent edits to the same tenant would otherwise clobber each other (last-writer-wins on the whole row). To prevent this, read row_version from GET /admin/v1/tenants/{id} (or the list) and send it back as expected_version on the PATCH. If the tenant changed under you, the PATCH returns 409 { "error": "stale", "row_version": <current> } and writes nothing — reload and retry. On success the PATCH returns the bumped row_version; adopt it for your next save. Concurrent edits to the same field remain last-writer-wins by nature; the precondition prevents silent loss of a concurrent edit to a different field. Every successful PATCH advances row_version — except a four-eyes 202 held response, which returns before the transaction and therefore leaves the caller's token still valid — including one that omits expected_version — so a caller that does send the precondition always detects a concurrent write, whatever the other caller did. expected_version is therefore opt-in: omit it and your own request keeps the legacy last-writer-wins behaviour (no 409), but it still bumps the version and cannot silently slip past a concurrent precondition-checking editor. This mirrors the workflow-draft updated_at precondition.

⚛️ Atomicity — a PATCH applies all-or-nothing. All of a PATCH's field writes and the row_version bump are applied inside a single database transaction. If any write fails mid-request, the whole edit is rolled back: the tenant row — including its row_version — is left exactly as it was before the request, and the PATCH returns 500. Nothing is left half-applied, so a retry sees the unchanged row and does not spuriously 409 on a phantom version bump, and a concurrent GET can never observe a partially-updated tenant (e.g. the new branding but the old regions). A validation error (400) is detected before the transaction opens, so it likewise writes nothing. Compliance audit-log entries for the change and the per-gateway config-cache refresh (for residency-affecting fields) are applied after the transaction commits, so they reflect only a change that actually persisted.

To disable request logging for a tenant (platform admin only):

curl -X PATCH https://<your-gateway-host>/admin/v1/tenants/{id} \
  -H "Content-Type: application/json" \
  -d '{"request_logging_disabled": 1}'

SIEM egress target (siem)

siem configures an external destination the gateway delivers a copy of each event to (see SIEM targets). Because it is an operator-chosen egress target — a mis-set value makes the gateway connect to an attacker-named address — it is platform-admin-only to set (in the 403 list above) and schema-validated at the write boundary (owner ruling: malformed input is rejected 400, never clamped or coerced). The same object is accepted on POST /admin/v1/tenants and, as config.siem, on the gateway routes (Updating gateway config).

Accepted shape (object; unknown top-level fields are rejected 400):

Field Applies to Rule
type all Required. One of splunk_hec, elasticsearch, vector, syslog.
url splunk_hec / elasticsearch / vector Required for these types. A string that passes the gateway's SSRF egress check — an http/https URL to a public host. A loopback / RFC1918 / link-local / 169.254.x / non-http(s) / userinfo-bearing URL is rejected 400. Max 2048 chars.
token / index / username / password HTTP types Optional strings, max 512 chars, no control characters.
host syslog Required. A bare hostname or IP that passes the same egress check (a /, @, or whitespace is rejected). Max 512 chars.
port syslog Optional integer in [1, 65535] (default 514). A non-integer, fractional, string, or out-of-range value is rejected 400.
protocol syslog Optional; tcp or udp (default udp).
format syslog Optional; cef or rfc5424 (default cef).
events all Optional array of all / security / blocked / guardrail / scrubbed (max 16). An object, a non-string element, or an unknown name is rejected 400.

Rejected (each → 400, nothing stored): a non-object siem; an unknown type; a missing url (HTTP types) or host (syslog); a URL/host the SSRF check refuses; a bad port/protocol/format; a malformed events; any unknown field; any field over its length cap. A tenant_admin change is refused 403 before schema validation (authz precedes format); an omitted or unchanged-echo siem is a 200 no-op.

Egress restriction (delivery). The validated target is also re-checked at delivery time: the HTTP transports connect to the pinned, re-validated IP (closing the DNS-rebinding window), and the syslog transports re-run the same is_safe_url check and set a socket timeout before connecting — so a stored target that later resolves to a private address delivers nothing (logged at WARN). Binding the syslog socket to the exact resolved IP (eliminating the sub-millisecond re-resolve window that remains) is a tracked follow-up.

White-label text branding

Per-tenant white-labeling of the in-app product identity, editable by admin or tenant_admin (edit-only — not accepted on tenant creation). Every value is untrusted input, validated at the trust boundary and stored fail-closed; each field is cleared by sending JSON null or an empty/whitespace-only string, which reverts that surface to the platform default. The values ride the authenticated /me payload (and, for the non-sensitive subset, the public login-branding endpoint) and are rendered verbatim by the app, so control characters and non-http(s) link URLs are rejected.

curl -X PATCH https://<your-gateway-host>/admin/v1/tenants/{id} \
  -H "Content-Type: application/json" \
  -d '{
        "brand_product_name": "Acme Assistant",
        "brand_home_greeting": "Welcome to Acme Assistant",
        "brand_chat_disclaimer": "Acme Assistant can make mistakes. Verify important information.",
        "brand_sidebar_links": [
          { "label": "Docs",   "url": "https://docs.acme.example" },
          { "label": "Status", "url": "https://status.acme.example" }
        ]
      }'
Field Accepted Rejected → 400
brand_product_name string ≤ 80 chars (trimmed); null/empty clears non-string, over-length, or any control character
brand_home_greeting string ≤ 200 chars (trimmed); null/empty clears non-string, over-length, or any control character
brand_chat_disclaimer string ≤ 128 chars (trimmed); null/empty clears non-string, over-length, or any control character
brand_sidebar_links a JSON array of at most 4 { "label", "url" } objects; null/[] clears not an array, more than 4 items, a missing/empty/over-40-char label, a missing/over-512-char url, a control character, or a URL that is not an absolute http:///https:// URL (a javascript:, data:, protocol-relative //host, or relative/scheme-less URL is rejected as an XSS vector)

Product-name precedence (one source of truth). The default in-app product name is "Myra AI Workspace". When brand_product_name is set, it overrides that default only in the in-app product strings — the browser tab title, the app-chrome wordmark's accessible label, the login-card title, and the tenant's transactional emails (the login-code and invitation emails' subject, body, header, and footer). It does not change the product name used in the documentation or marketing surfaces, which remain "Myra AI Workspace" regardless of tenant branding. Clearing the field reverts every in-app surface to the default (each email also falls back to the default fail-closed).

Per-tenant cloud region (Bedrock / Vertex)

A tenant carries a default cloud region for the region-bearing providers, inherited by every gateway under it that sets no region of its own (see Region resolution). Platform-admin-only: a tenant_admin changing either field is rejected 403; an unchanged echo (null/"" while unset, or the stored token verbatim) is a 200 no-op.

curl -X PATCH https://<your-gateway-host>/admin/v1/tenants/{id} \
  -H "Content-Type: application/json" \
  -d '{"bedrock_region": "eu-central-1", "vertex_region": "europe-west4"}'
Field Accepted Rejected
bedrock_region A known EU AWS region token (eu-central-1, eu-west-1, eu-west-3, eu-north-1, eu-south-1, eu-south-2); or null / "" to clear (inherit the env / US default). Any non-EU region (e.g. us-east-1), unknown/garbage token, uppercase, embedded CR/LF or /, over-long (> 32 chars), or a non-string → HTTP 400 with allowed listing the valid options.
vertex_region A known EU GCP region token (europe-west3, europe-west10, europe-west1, europe-west4, europe-west9, europe-west8, europe-west12, europe-central2, europe-north1, europe-north2, europe-southwest1); or null / "" to clear. As above.

Values are validated unconditionally as EU-member regions at the trust boundary (region.is_eu, fail closed) — independent of whether eu_region_routing enforcement is on. Enumerate the valid options with GET /admin/v1/cloud-regions (below) rather than hardcoding them. The two columns are written atomically; a PATCH that sets only one preserves the other.

Web-search provider default

A tenant also carries a default web-search provider its gateways inherit — the tenant-level twin of the per-gateway web_search.provider (see Web Search — Supported providers). Platform-admin-only: a tenant_admin changing it is rejected 403; null while unset, or the stored value verbatim, is a 200 no-op ("" stays that field's own 400).

curl -X PATCH https://<your-gateway-host>/admin/v1/tenants/{id} \
  -H "Content-Type: application/json" \
  -d '{"web_search_provider": "linkup"}'
Field Accepted Rejected
web_search_provider "brave" or "linkup" (lower-cased — "BRAVE"/"Linkup" resolve); or JSON null to clear (no tenant default). Any other value — an unknown provider ("google"), a value with surrounding whitespace ("BRAVE ", " linkup"; not trimmed), an empty string "", a number, boolean, object, or array → HTTP 400 with allowed listing the valid providers.

Unlike the region fields, "" does not clear — the enum is strict (the SPA sends JSON null for the "no tenant default" option). The value is validated at the trust boundary against the search-adapter registry (utils.search.normalize_known_provider) — the client is never the authority. A change is audited (tenant.web_search_provider_changed) and flushes the tenant's gateway-config cache so it takes effect immediately.

Resolution + the EU floor. The tenant default fills a gateway's web_search.provider only when the gateway already has a web_search block and sets no provider of its own (a tenant default never enables web search on a gateway that has none). A per-gateway provider overrides the tenant default — except under an armed EU residency floor (eu_region_routing on, per-gateway or inherited): a non-EU per-gateway provider is then clamped up to an EU tenant default (a gateway may still choose another EU provider). When the tenant default is absent or non-EU the gateway value is left alone and the search-egress gate still fail-closes a non-EU query under enforcement. The gateway's web_search.api_key must be valid for the effective provider (one key per gateway).

Gateways saved before this feature carry an explicit "brave" provider (the old form always stamped one), so on a non-enforcing tenant the new default does not retroactively apply to them — re-save such a gateway with the Tenant default option to make it inherit.

Auto model-selection policy

A tenant carries an auto_default policy that governs which model the server picks for an Auto conversation (one created with no explicit model pin). It reorders the selection objective over the same eligible candidate set — the eligibility guards (data-residency offer set, plan allowlist, tool-calling capability, local-only tier) are unchanged, so whatever Auto picks under any policy is still a model the workspace is actually allowed to run.

curl -X PATCH https://<your-gateway-host>/admin/v1/tenants/{id} \
  -H "Content-Type: application/json" \
  -d '{"auto_default": "best"}'
Field Accepted Rejected
auto_default Exactly one of "cheapest_tool_capable" (default), "balanced", or "best". Any other value — an unknown policy ("premium"), a different case ("BEST"), surrounding whitespace (" best", "best "; not trimmed), an empty string "", JSON null, a number, boolean, object, or array → HTTP 400 with allowed listing the valid policies.
  • cheapest_tool_capable (the column default — today's behaviour): the cheapest tool-calling model, preferring the preferred providers.
  • balanced: the second-highest quality tier available across the fleet (falls back to the single best tier when only one exists).
  • best: the highest quality tier available across the fleet.

The column is NOT NULL (default cheapest_tool_capable), so there is no "inherit" state and JSON null is rejected, not a clear. The value is validated at the trust boundary against the one policy authority (core.auto_policy.is_valid) — the client never sets the routing objective. A change is audited (tenant.auto_default_changed); it needs no gateway-config flush because the policy is read at route-resolve time, not folded into gateway config. A stored value the server no longer recognises fails safe to cheapest_tool_capable.

Cloud-region options

curl https://<your-gateway-host>/admin/v1/cloud-regions

Returns the ordered EU region options for the tenant SELECT, grouped by provider scheme (the single source of truth is the backend region allowlist — the SPA must not hardcode it):

{
  "aws": [ { "value": "eu-central-1", "label": "Frankfurt, DE" }, ... ],
  "gcp": [ { "value": "europe-west3", "label": "Frankfurt, DE" }, ... ]
}

Any authenticated admin or tenant_admin may read it (static, non-sensitive labels).

Deleting a tenant (soft delete)

curl -X DELETE https://<your-gateway-host>/admin/v1/tenants/{id} \
  -H 'Content-Type: application/json' \
  -d '{"confirm": "<tenant-slug>"}'

Sets deleted_at on the tenant — the decommission step. Every row is retained (so the tenant can still be exported before purge), but the tenant is disabled: it is hidden from listings and, as of the fail-closed hardening, stops resolving on every authentication and routing path. A soft-deleted tenant's gateways no longer route, its API tokens no longer authenticate, its users can no longer sign in (session, email OTP, OIDC/SAML, SCIM all reject), SSO discovery / …/start no longer respond for it, and its public conversation shares (/share/{token}) go dark immediately — the snapshot returns a public 404, not at purge time — each resolver enforces tenant.deleted_at IS NULL in SQL and denies (401/404) otherwise. This is the step to take at contract notice; the permanent erasure below is the contract-end companion, and the restore below is its reversible undo.

This is a destructive-action endpoint, guarded fail-closed:

  • Actor gate (a combination). A platform administrator (role admin) may delete any tenant. An organisation administrator (role tenant_admin) may delete only their own tenant — the target id must equal the actor's session tenant (derived server-side, never from the path); any other tenant → 403 (or 404 if that tenant does not exist). The check is on the enum role, so the delete is not reachable through a custom role that merely grants TENANT_SETTINGS_MANAGE — deleting the organisation is not a delegable capability. A same-tenant member / viewer (even one holding a delegated TENANT_SETTINGS_MANAGE) → 403; unauthenticated → 401.
  • Confirmation token. The request body must contain {"confirm": "<tenant slug>"} matching the tenant's own slug exactly (a "type the name to confirm" guard, shared with the purge below). A missing / non-string / empty / whitespace-only / mismatched value → 400, and nothing is deleted (deleted_at stays NULL). The id is already in the URL, so the id is not accepted as confirmation.

Lifecycle: decommission → disabled → (restore | purge). Soft-delete is reversible at the product level — a platform admin can restore it — or terminal via the purge below. Because a decommissioned tenant can no longer authenticate, it generates no new PII / request-log rows, which is what makes the purge's erasure guarantee (below) complete.

Scheduled and inbound-webhook agent runs stop too. The scheduler's due-work queries exclude soft-deleted tenants (tenant.deleted_at IS NULL), so a decommissioned tenant's scheduled runs and pending inbound-webhook runs no longer fire.

Cache caveat (bounded). The soft-delete now immediately flushes the per-worker gateway-config cache for every gateway of the tenant, cross-worker — so the next request re-reads storage, sees deleted_at, and refuses; the previous ≤60 s window is closed. Routing therefore stops at once, which also closes the only path the ≤300 s auth-token cache could have used (a rare service token still needs the gateway config to resolve, and that now fails immediately). This tightening matters because it prevents a late in-flight inference from writing new request_log / request_log_legs rows after decommission that a subsequent purge could miss.

Restoring a soft-deleted tenant

curl -X POST https://<your-gateway-host>/admin/v1/tenants/{id}/restore

Clears deleted_at — the product-level undo of the soft delete, so the decommission step is genuinely reversible. The tenant becomes reachable again on every authentication and routing path, and the per-worker gateway-config cache is flushed cross-worker so inference routing resumes immediately (admin sign-in and admin routes resume the moment deleted_at is cleared — they read the tenant per request, uncached).

Guarded fail-closed:

  • Platform administrators only (role admin; require_platform_admin). An organisation administrator, or any holder of a delegated TENANT_SETTINGS_MANAGE, → 403; unauthenticated → 401. Restore is a Myra-operations recovery lever (strictly higher than the soft delete's own tenant_admin arm) — in particular it is how a tenant_admin who deleted their own organisation is brought back.
  • Already live (not soft-deleted) → 200 { "ok": true, "already_active": true } (idempotent no-op — re-running restore converges).
  • Purged or non-existent → 409 { "error": "cannot restore a purged or nonexistent tenant" }. A purged tenant has been hard-deleted, so it is indistinguishable from one that never existed; either way there is nothing to restore. This also closes the restore-vs-purge race: if a purge commits between the lookup and the write, the update affects zero rows and the call returns 409, never a false success.
  • A transient database fault during the lookup → 503 (retryable); a failure of the clear-deleted_at write itself → 500.

On success (200) the response body is { "ok": true }. Restore is audited (tenant.restored). Note this makes the soft delete reversible; the purge below is not.

Purging a tenant (irreversible hard delete)

At contract end, DELETE /tenants/{id}/purge permanently and irreversibly erases all of a tenant's data across every tenant-scoped table — users, conversations, messages and attachments, projects and knowledge (documents, segments, vector embeddings), agents and workflows, MCP connectors, gateways, provider keys, request and usage logs, SSO/SAML/SCIM configuration, and the tenant record itself. It writes a Löschprotokoll (deletion-protocol) audit entry recording the per-category counts.

curl -X DELETE https://<your-gateway-host>/admin/v1/tenants/{id}/purge \
  -H 'Content-Type: application/json' \
  -d '{"confirm": "<tenant-slug>"}'

This is a destructive-action endpoint, guarded fail-closed:

  • Platform administrators only (role admin). A tenant_admin, ki_manager, or member → 403; unauthenticated → 401. (Strictly higher than the soft delete, which a tenant_admin may perform.)
  • The acting administrator must not be a member of the target tenant (external actor). Purging a tenant you belong to → 409. This guarantees an administrator can never lock themselves — or the platform — out.
  • The tenant must already be soft-deleted (deleted_at set). A live tenant → 409 "tenant must be soft-deleted before it can be purged". Purge operates on a decommissioned tenant, and because soft-delete disables all authentication/routing for that tenant (see above), no in-flight traffic can write new rows mid-purge — the premise the purge relies on is now enforced, not merely assumed.
  • Confirmation token. The request body must contain {"confirm": "<tenant slug>"} matching the tenant's own slug exactly (a "type the name to confirm" guard). A missing / non-string / empty / whitespace-only / mismatched value → 400, and nothing is deleted. (The id is already in the URL, so the id is not accepted as confirmation.)
  • An unknown or already-purged tenant → 404.

On success (200) the response body is { "ok": true, "purged": { <table>: <count>, … } }.

The purge runs as one transaction. If it hits a transient database serialization conflict (a concurrent write racing the purge), the conflicting statement is first re-read and re-applied in place (bounded, inside the same transaction), and if the conflict still persists the whole transaction is automatically rolled back and retried with a short bounded backoff (up to three additional attempts); the caller sees the eventual success. If the conflict persists across every attempt, the purge returns 503 { "error": "...", "retryable": true } naming the conflicting step: the transaction was fully rolled back — nothing was deleted, the tenant is still soft-deleted, and re-issuing the same request is safe and idempotent (it converges once the write contention subsides). A 500 indicates a non-transient failure, never a transient race.

⚠️ This cannot be undone. Unlike the soft delete — which a platform admin can restore — the purge is irreversible: there is no restore for purged data. For a reversible removal, use the soft delete above. For a machine-readable copy of the data before erasure, take the exit data export first.

Audit retention. The tenant's audit-log rows are retained but IP-scrubbed (their actor_ip is nulled) rather than deleted, preserving the Nachvollziehbarkeit (traceability) trail; the shared operational model_error triage queue rows are anonymized (tenant identifiers and diagnostic content nulled), not removed.

Application logo & favicon (app-chrome branding)

Upload the per-tenant image assets shown in the application chrome and login page in place of the platform-default marks. Three assets share one route shape, differing only by path suffix:

Asset Path suffix Shown
Dark-theme logo /logo app chrome + login, in the dark UI theme (and as the light-theme fallback when no light logo is set)
Light-theme logo /logo-light app chrome + login, in the light UI theme
Favicon /favicon the browser tab icon

Each upload is untrusted input, validated at the trust boundary; when an asset is unset the surface falls back to the platform default. The logo pair mirrors the platform's own /logo.svg (dark) and /logo-light.svg (light) marks: the correct variant renders per UI theme, and a tenant that sets only the dark logo still shows it in both themes (the light theme falls back to the dark logo, then to the platform default).

# Upload / replace (base64-encoded PNG or JPEG in a JSON body). Same shape for
# /logo, /logo-light, and /favicon.
curl -X POST https://<your-gateway-host>/admin/v1/tenants/{id}/logo \
  -H "Content-Type: application/json" \
  -d '{"data": "<base64 image bytes>"}'
#   → { "ok": true, "mime": "image/png", "version": "<content-hash>" }

# Fetch (returns the bytes base64-encoded, for rendering as a data: URL)
curl https://<your-gateway-host>/admin/v1/tenants/{id}/logo-light
#   → { "mime": "image/png", "version": "<content-hash>", "data": "<base64>" }

# Remove (the surface reverts to its fallback / the platform default)
curl -X DELETE https://<your-gateway-host>/admin/v1/tenants/{id}/favicon

Accepted input (validated by magic bytes — never the client-declared type — with everything else rejected, fail-closed; nothing is stored on any rejection). All three assets use the same validator, so a favicon is a PNG or JPEG (not .ico; SVG is rejected as script-capable), and the size/dimension caps are a deliberately generous upper bound rather than favicon-tuned:

Rule Accepted Rejected → status
Format PNG or JPEG only, identified by leading magic bytes SVG (script-capable), GIF, BMP, WEBP, TIFF, PDF, .ico, or any other/spoofed type → 415
Size ≤ 512 KB decoded larger → 413. A Content-Length-bearing upload is rejected at the boundary before the body is read, so it always returns a clean 413 Payload Too Large rather than a server error, regardless of buffering; a chunked upload (no Content-Length) falls back to the same 413 after decode.
Decodability header parses to real pixel dimensions truncated file, or magic bytes glued onto non-image data → 400
Dimensions each side 1–4000 px and ≤ 4,000,000 px total a "decompression bomb" (a tiny file declaring a huge canvas) or an over-4000 px side → 400
Encoding valid base64 (an optional data:<mime>;base64, prefix is tolerated) non-base64 or empty → 400

Authorization (fail-closed). POST / DELETE require admin or tenant_admin, and the caller must own the tenant (a tenant_admin of another tenant → 403; unknown tenant → 404; unauthenticated → 401). GET requires only tenant membership (any member may render their own tenant's asset; a member of another tenant → 403), so the chrome can display it for every user. GET on a tenant with no such asset returns 404; a transient database error returns 503 (retryable) so the chrome degrades to the default rather than caching a miss.

The three assets are stored on the one tenant branding row (storage.get_tenant_asset); the dark logo's bytes + magic-derived MIME are additionally reused by document-export branding — one upload, not a second store.

Exit data export (JSON + CSV)

Produce a complete, machine-readable handover of a tenant's data at contract end — configurations (agents, workflows/agent schedules, policies, MCP connectors, interfaces, roles), conversations, statistics, and logs — in both JSON and CSV, delivered as one ZIP package.

# Default: a ZIP containing manifest.json + one .csv per table
curl -OJ https://<your-gateway-host>/admin/v1/tenants/{id}/export
#   → tenant-<slug>-export.zip

# Just the JSON manifest (programmatic use)
curl 'https://<your-gateway-host>/admin/v1/tenants/{id}/export?format=json'

Authorization (fail-closed). Requires admin or tenant_admin, and the caller must own the requested tenant: a tenant_admin may export only their own tenant; a platform admin may export any tenant. A plain member / ki_manager, or a tenant_admin requesting a different tenant, receives 403; an unknown tenant returns 404; an unauthenticated caller 401. A read failure on any primary section (interfaces, agents, agent schedules, MCP connectors, workflows, workflow versions, workflow runs, workflow step runs, users, conversations, messages, memories, knowledge files, tenant prompts, tenant prompt versions, config approvals, request logs, audit logs) fails the whole export with 500 — those sections are never a silently-incomplete "complete copy". The groups, projects and statistics sections are best-effort and bracketed by a database-liveness check.

Package contents. manifest.json carries the full nested export; each *.csv is a flat table of the same data (spreadsheet-openable, with CSV formula-injection neutralized per CWE-1236):

File Contents
manifest.json Everything below, nested, plus caps/truncated/omitted metadata
tenant.csv Tenant config & policy — every stored tenant configuration column: plan, budget & period, org system instruction, all feature switches (agents/workflows/scheduled-tasks/playground/request-logging/…), retention days, trial end, code-interpreter limit, cloud regions (Bedrock/Vertex), web-search provider, TTS engine/voice, white-label branding (product name, home greeting, chat disclaimer, colour, font), and finance settings. Structured values (slash commands, prompt examples, sidebar links) are in the JSON manifest's tenant object
interfaces.csv Gateways (interfaces); routing/guardrail config is in the JSON manifest
agents.csv Saved agents (name, model, provider, instructions)
agent_schedules.csv Agent-level schedules (name, cadence, prompt) — the output_action target is JSON-only, reduced to its kind
mcp_connectors.csv MCP tool-server registrations (name, server URL, auth type, scope)
workflows.csv Visual Workflow Baukasten definitions (name, status, published version, budget) — the graph is JSON-only
workflow_versions.csv Published immutable workflow versions (semver, publisher) — the graph snapshot is JSON-only
workflow_runs.csv Workflow run history (status, cost per run, named approver „freigegeben von …") — the restored run state is JSON-only
workflow_step_runs.csv Per-step execution records (step, node type, status, cost, duration)
webhook_triggers.csv, workflow_email_triggers.csv Inbound trigger bindings: which agent/workflow an inbound webhook or email fires, plus the enable toggle. The webhook token is the URL path (no secret); the email binding carries no mailbox/IMAP routing (that is Myra-mail-infrastructure-internal)
groups.csv, group_members.csv User groups and membership (role assignments)
roles.csv, role_permissions.csv, role_assignments.csv Tenant custom RBAC roles (admin-authored, origin='user'): each role's name/description, its permission grants (perm_key), and which users hold it. Platform-defined system roles are excluded (not tenant data)
projects.csv Projects and their instructions
users.csv Users and their roles (+ per-user retention Löschfrist)
conversations.csv, messages.csv Conversation history (all tenant users)
memories.csv User memories (content, type, source, owner)
knowledge_files.csv Knowledge / context layer: parsed document text (extracted_text), per file
tenant_prompts.csv, tenant_prompt_versions.csv The tenant prompt library ("tenant-specific model adaptations") + its version history
config_approvals.csv Four-eyes config-approval history: who requested / approved / denied which config change, when, and its status (the proposed payload is JSON-only, in manifest.config_approvals[].payload)
statistics.csv, top_models.csv Usage aggregates
request_logs.csv Request metadata (no message bodies, no end-user IPs), including the caller's own correlation id (client_request_id)
audit_logs.csv Control-plane audit trail (an SSO row carries the IdP entityID / issuer in idp_entity_id; the tenant is in entity_id)

Exit-handover data domains. The exit handover explicitly carries, in open formats, the tenant's memories (memories), the knowledge / context layer as parsed document text (knowledge_files[].extracted_text), and the tenant-specific model adaptations — the admin-curated tenant prompt library and its version history (tenant_prompts, tenant_prompt_versions, live prompts only; soft-deleted prompts are excluded, like soft-deleted conversations). The per-tenant/user/project retention Löschfrist config is included on the tenant/users/projects objects. The four-eyes config-approval history (config_approvals) carries every requested/approved/denied config change with its status, approver, and the proposed payload (all statuses; secret-named keys in the payload are scrubbed, and a payload past an internal per-export byte budget is dropped per row — flagged payload_omitted and counted in config_approval_payload_omitted_count). The inbound trigger bindings are carried as siblings: webhook_triggers (which agent/workflow an inbound webhook fires, with the URL token — no secret) and workflow_email_triggers (which workflow an inbound email fires, plus the enable toggle). The email binding is the pure workflow-to-email link only: it exports no mailbox host, IMAP user, or password — that routing lives with the mail-ingest infrastructure (Myra-internal), not the tenant's portable config, so nothing infrastructure-internal leaves in the handover. manifest.schema_version is 3 for exports carrying the config-approval history (2 carried the LB 2.8 sections without it).

Excluded (by design). Vector/embedding indexes (derived, rebuildable) and the original binary knowledge files (a separate blob store — the parsed extracted_text is included), encrypted provider API keys (including any embedded in gateway/interface config), MCP connector credentials (OAuth access/refresh tokens and bearer secrets — a separate store), SIEM credentials, request/response message bodies and end-user IP addresses in request logs, audit before/after diffs, and per-gateway managed-model enablement grants (Myra-pool routing intent, no key material). An agent schedule's output_action is reduced to its kind (its webhook URL / channel target is credential-equivalent and withheld), and an MCP server_url has any embedded basic-auth userinfo stripped. Workflow run resume tokens (single-use approval tokens) and any secret column are never selected. Request logs, audit logs, agent schedules, MCP connectors, workflows, workflow runs, projects, memories, knowledge files and tenant prompts are capped, and agents are limited to 500 per interface (see the manifest's caps and truncated fields); truncated: true for a section means it hit its cap. Knowledge extracted_text (which can be very large) is additionally bounded by a cumulative byte budget (caps.knowledge_text_budget_bytes): once exhausted, further knowledge rows are emitted metadata-only with extracted_text_omitted: true, and the count of such skipped rows is reported in knowledge_text_omitted_count (conservative — it may include rows that were genuinely empty, and never under-counts a real omission) — a signal distinct from a whole-row cap. Conversations and messages are complete (not capped). excluded from the tenant section specifically: the SIEM ingest credential (siem_config, secret), the internal optimistic-concurrency token (row_version), the logo cache-bust flag (brand_logo_version — the logo image is out of the data-export's scope), the voucher bonus-credit fields (bonus_credit_usd/bonus_credit_expires_at, the bonus-credit domain is ruled out of the handover), and the platform-admin-set budget ceiling (platform_cap_usd, a Myra governance value rather than the tenant's own portable configuration). Every other tenant column is exported; a drift guard (test_tenant_export_completeness) fails the build if a new tenant column is added without an explicit export-or-exclude decision.

Policies & roles. These are already in the package: the tenant's org policy / system instruction is in tenant.csv, and slash commands and prompt examples are in the tenant object of the JSON manifest (structured values are JSON-only, not in the CSV); project role assignments are in groups.csv + group_members.csv (group grants) and per-user platform roles in users.csv. The tenant's custom RBAC roles (admin-authored, origin='user') are exported in their own roles domain — each role with its permission grants and the users it is assigned to (roles.csv / role_permissions.csv / role_assignments.csv). The 7 platform-defined system roles are not tenant data and are excluded (the export getter fences origin='user').

Delivery (bounded memory). The ZIP download adapts to tenant size so a very large tenant cannot exhaust worker memory or hit a proxy timeout. A small tenant is assembled in memory and sent as a single ZIP (unchanged; any read fault is reported as a clean 500 before a byte is sent). A large tenant — one whose message corpus exceeds an internal memory budget — is streamed: the conversation/message corpus is keyset-paginated and written incrementally into a data-descriptor ZIP, so peak memory stays bounded to roughly one page regardless of tenant size. The package is complete either way — the streamed path paginates, it never caps or drops the corpus (conversations and messages remain uncapped). Because a streamed response has already begun with 200 OK, a read fault that occurs mid-stream cannot be turned into a 500; instead the archive is deliberately left without a central directory, so unzip, Python zipfile, macOS Finder and Windows Explorer all reject it as corrupt rather than presenting a truncated download as a complete handover — re-run the export. The streamed and in-memory packages carry identical content. ?format=json always returns the full manifest assembled in memory (a single JSON body cannot be split like a ZIP); prefer the default ZIP download for a very large tenant. Like the in-memory build, the export is a point-in-time snapshot taken across many queries (not a single serialisable transaction), so a write that lands while the export is running may or may not be captured — exit exports are taken at contract end when the tenant is winding down. (The streamed archive is ZIP64-aware: a package that crosses the 4 GiB / 32-bit-offset ceiling automatically emits 64-bit offsets plus a ZIP64 end-of-central-directory, so a very large tenant export completes and still opens in unzip, Python zipfile, macOS Finder and Windows Explorer.)

Reactivation metrics

curl https://<your-gateway-host>/admin/v1/tenants/{id}/reactivation-metrics

The counters behind the Member reactivation card on the tenant Analytics page (Lifecycle e-mails). Authorization: TENANT_SETTINGS_MANAGE (admin | tenant_admin) and access to the tenant — the same gate as /tenants/{id}/analytics, because per-member states in a small tenant identify people; a role without TENANT_SETTINGS_MANAGE (e.g. ki_manager) is 403.

Response. enabled_effective (0|1 — what the tri-state resolves to right now), mails_sent_7d, mails_sent_30d, reactivated_7d (members who became active within 7 days of a mail sent in the last 30 days), opt_outs_total, opt_outs_30d, and states — { never_activated, lapsed, dormant, active, excluded, total } counting the tenant's live members by the same classification the sweep uses. states is computed live and cached for 5 minutes per tenant; the other fields are always live. A database fault is 503 (never an all-zero body).

Deletion protocol (Löschprotokoll)

After a tenant has been purged, retrieve the machine-produced deletion protocol — an open, machine-readable JSON record of what was deleted and when. The tenant purge writes the counts as a tamper-evident tenant.purged audit entry that survives the tenant's deletion; this endpoint renders that entry as the deliverable the departing customer is owed (within four weeks of contract end).

curl https://<your-gateway-host>/admin/v1/tenants/{id}/deletion-protocol

Authorization (fail-closed). Platform admin only (401 unauthenticated, 403 otherwise). There is deliberately no tenant-ownership gate — the tenant no longer exists after a purge, and the tenant's own admins are gone, so an operator retrieves the protocol and hands it to the customer. Because the tenant's users are erased, this is not a self-service endpoint.

Ordering. Run the exit data export before the purge (data is unexportable after erasure); the deletion protocol is available after the purge.

Responses.

  • 200 — the deletion-protocol document (below).
  • 404 — this tenant has not been purged (no tenant.purged record).
  • 500 — a database fault or a corrupt audit payload (fail-closed; nothing is guessed).

Document shape (counts only — no deleted content). The protocol reproduces per-table row counts and timestamps, never the deleted rows' content (re-persisting erased PII would defeat the very erasure it attests):

Field Meaning
artifact_kind "tenant_deletion_protocol"
tenant_id, purged_at The purged tenant and the erasure time (unix seconds)
actor { id, type } of the admin (or system reaper) who ran the purge
reason e.g. contract_end_offboarding
total_rows_deleted Sum of genuinely deleted rows only
rows_scrubbed, rows_reassigned, rows_anonymized Rows that were retained and PII-cleared / re-owned (reported separately, never counted as deletions)
rows_retained Rows kept whole for a statutory limitation period (AVV consent evidence) — retained, not deleted, and reported separately from the scrub/reassign/anonymize counts
schema_version, generated_at The protocol schema version (1) and the generation time (unix seconds)
table_coverage [{ table_name, count, op }] — op is delete / scrub / reassign / anonymize
deleted_counts The raw per-table counts, verbatim
audit_evidence The tamper-evident audit-chain anchor: audit_log_id, audit_chain_enabled, sealed, seal_seq, chain_hash, prev_hash. Verify the deletion's integrity against this anchor via the audit-chain verify surface. sealed: false means the record was not yet sealed by the asynchronous sealer, or the hash-chain is not enabled in this environment (audit_chain_enabled: false).
retention_evidence (present for an unattended self-serve retention purge) non-PII proof the warning ladder preceded erasure

OIDC SSO configuration

Configure a tenant's OpenID Connect provider so its users can sign in via SSO (see Admin API authentication → Single sign-on). Create, update, and delete require the SSO_MANAGE permission (held by the admin and tenant_admin roles and any custom role granted it); a read (GET) requires tenant access. A step-by-step provider walkthrough is in Administration → Security → Single sign-on.

curl -X PUT https://<your-gateway-host>/admin/v1/tenants/{id}/sso-config \
  -H "Content-Type: application/json" \
  -d '{
    "issuer": "https://login.microsoftonline.com/<dir>/v2.0",
    "client_id": "00000000-0000-0000-0000-000000000000",
    "client_secret": "•••",
    "subject_claim": "oid",
    "allowed_email_domains": "corp.example, sub.corp.example",
    "entra_tenant_id": "11111111-1111-1111-1111-111111111111",
    "allow_guest_signin": false,
    "enabled": true
  }'

Accepted input (validated at the trust boundary; rejected with 400 otherwise):

Field Rule
issuer Required. Must be an https:// URL and must resolve OIDC discovery.
client_id Required, non-empty.
client_secret Required on first config; on update, omit to keep the stored secret or send a new one to replace it. Stored encrypted; never returned.
subject_claim sub or oid only (a mutable claim like email is rejected — it would allow recycled-identifier takeover). Default sub.
scopes Optional. Space-separated OAuth scopes requested at the IdP. Default openid email profile.
allowed_email_domains Required, non-empty. Comma/whitespace-separated; normalized to a lowercase comma list. This is the verified-domain allowlist — the tenant's IdP may only sign in users at these domains.
entra_tenant_id Optional. The customer's Microsoft Entra directory GUID (8-4-4-4-12 hex). When set, this enables Entra mode: an id_token whose tid claim does not match this directory is rejected at login (a token from a different Entra directory can't sign in even if iss/aud line up). Normalized to lowercase. Omit / null for generic OIDC / ADFS (backward-compatible). A malformed value is rejected (never silently dropped to non-Entra mode).
allow_guest_signin Optional boolean or 0/1 (default false/0 — members-only). When false, an Entra B2B guest identity (a #EXT# marker in the upn/preferred_username claim) is rejected at login (403, audited). Set true to permit guests (still subject to the domain allowlist and link rules). Any other shape is rejected.
enabled Optional boolean (default true). When false, SSO is off and the tenant is not discoverable by email domain.

At login the identity is resolved from the token by claim precedence email → preferred_username → upn (Entra frequently omits email); when none of the three is present the sign-in is refused.

PUT is a full replacement: an omitted entra_tenant_id / allow_guest_signin resets to its default, so send the complete config. GET returns the config without the secret (a has_secret boolean instead) — including entra_tenant_id and allow_guest_signin so a client round-trips them. DELETE removes it.

Interactive OIDC login uses PKCE (RFC 7636, S256) end-to-end: the /start redirect carries code_challenge + code_challenge_method=S256, and the token exchange sends the matching code_verifier. This is required for SPA / public Entra app registrations and harmless for confidential apps.

SAML 2.0 SSO configuration

Configure a tenant's SAML identity provider (see Admin API authentication → Single sign-on (SAML 2.0)). Create, update, and delete require the SSO_MANAGE permission (held by the admin and tenant_admin roles and any custom role granted it); a read (GET) requires tenant access. Unlike OIDC there is no client secret — a SAML IdP signing certificate is public, so it is stored and returned in full.

curl -X PUT https://<your-gateway-host>/admin/v1/tenants/{id}/saml-config \
  -H "Content-Type: application/json" \
  -d '{
    "idp_entity_id": "https://sts.windows.net/<dir>/",
    "idp_sso_url": "https://login.microsoftonline.com/<dir>/saml2",
    "idp_signing_cert": "-----BEGIN CERTIFICATE-----\n…",
    "nameid_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
    "allowed_email_domains": "corp.example",
    "idp_slo_url": "https://login.microsoftonline.com/<dir>/saml2",
    "sign_authn_requests": false,
    "enabled": true
  }'

Accepted input (validated at the trust boundary; rejected with 400 otherwise):

Field Rule
idp_entity_id Required, non-empty.
idp_sso_url Required. Must be an https:// URL.
idp_signing_cert Required. PEM or base64 X.509. Public; returned in full on GET. Validated at save time: the certificate is X.509-parsed and its key-strength checked (RSA ≥ 2048-bit, EC ≥ 256-bit) by the same authoritative gate the login path uses — a malformed or key-weak cert is rejected with a precise 400 rather than 502-ing every future login. If that gate cannot be reached the save fails closed with 503.
idp_signing_cert_next Optional second cert to support rollover (both are accepted during the overlap). Validated at save time exactly like idp_signing_cert — a bad rollover cert is rejected at 400 instead of breaking all current logins the moment it is staged.
nameid_format Must be an accepted format (persistent / emailAddress / X509SubjectName / kerberos / WindowsDomainQualifiedName / unspecified). transient and entity are rejected — the NameID is the identity key. unspecified is accepted (Microsoft Entra's common default). Default persistent.
allowed_email_domains Required, non-empty. The verified-domain allowlist (as for OIDC).
group_attribute / group_mapping Optional. The assertion attribute name and a JSON allow-list mapping its values to internal group ids. Only listed values grant membership; mapping is tenant-scoped and never writes cross-tenant.
idp_slo_url Optional. https:// or empty. The IdP SingleLogoutService URL; empty means Single Logout is not offered (sign-out is local only).
sign_authn_requests Optional boolean (default false). When true, the gateway signs the AuthnRequest/LogoutRequest with the gateway-wide SP key provisioned by your operator; fails closed if no key is provisioned.
enabled Optional boolean (default true).

GET returns the full config (the cert is public); DELETE removes it.

SCIM credential

Generate the bearer token the tenant's identity provider uses to drive SCIM provisioning. Requires the SSO_MANAGE permission (held by admin/tenant_admin and any custom role granted it; GET requires only tenant access). The plaintext is returned once on generation (only the SHA-256 hash is stored); GET reports configured + the base URL, never the token.

curl -X POST https://<your-gateway-host>/admin/v1/tenants/{id}/scim-credential
# → { "token": "scim_…", "base_url": "https://<your-gateway-host>/scim/v2", "tenant": "…" }   (copy the token now)

Per-tenant subprocessor objections (deactivation)

When a customer exercises its Art. 28 Abs. 6 DSGVO right to object to a new or changed sub-processor, Myra Ops records the objection here. Recording it deactivates that sub-processor's LLM provider(s) for that one tenant only — routing never selects a deactivated provider (primary, fallback, failover, tool-loop, and the observed LiteLLM-overflow leg all refuse it with provider_objected / 403), and its models stop being offered in that tenant's model pickers. No other tenant is affected, and the tenant keeps a working service on the remaining providers. This is an operator capability (SUBPROCESSORS_MANAGE, platform admin) — a legal objection recorded by Myra, not a tenant self-service toggle. The set of providers a key deactivates comes from the subprocessor → provider mapping.

Method & path Purpose
GET /admin/v1/tenants/{id}/subprocessor-objections list this tenant's active objections (array; [] when none)
POST /admin/v1/tenants/{id}/subprocessor-objections record one — body { "subprocessor_key": "anthropic" }
DELETE /admin/v1/tenants/{id}/subprocessor-objections/{key} withdraw one (re-enables the provider)

Presence of a row is the active state — withdrawal is a row delete, so there is no active flag to keep in sync. Every route also enforces require_tenant_access (tenant scoping). Validation (fail-closed):

  • {key} — a register slug that is a current or upcoming sub-processor (a removed key → 404 subprocessor_key_unknown); malformed → 400 subprocessor_key_invalid.
  • POST requires the key to have ≥ 1 provider mapping — else 400 no_mapping (an objection must deactivate at least one concrete provider; it is never a silent no-op). Idempotent on (tenant, key).
  • DELETE of an objection that does not exist → 404 not_found.

Recording/withdrawing an objection audit-logs the change and flushes the tenant's gateway config cache, so the (de)activation takes effect on the next request. (The notification e-mail and 30-day objection window) will drive this same API automatically when its window closes.


Gateways

Listing gateways for a tenant

curl https://<your-gateway-host>/admin/v1/tenants/{tenant_id}/gateways

Response:

[
  {
    "id": "gw_xyz789",
    "tenant_id": "ten_abc123",
    "slug": "production",
    "config": { "auth_required": true, "cache_ttl": 300 },
    "purpose": "production",
    "configured_providers": ["openai", "anthropic"],
    "offer_restricted": false,
    "offerable_providers": [],
    "managed_models": { "anthropic": { "claude-opus-4-6": true } },
    "web_search_configured": true,
    "web_search_setup_pending": false,
    "created_at": 1742551200
  }
]

💡 Note: By default this listing returns only production gateways. Pass ?include_test=1 to also include test, benchmark, and archived gateways (used by the admin Gateways page that manages fixtures). Each returned row carries its purpose field and a configured_providers array listing the providers that have a key configured on the gateway.

💡 Myra-provided models (managed_models). A row carries managed_models — the Myra-provided keyless model grants on the gateway, as { provider: { model: true } } — only with entries that would actually route: the gateway has the grant and the tenant has a dollar budget cap (budget_usd) and the model is still in the server SUPPORTED allowlist with a live (non-deprecated) price row and the provider survives the gateway's residency/allowlist gates. Anything else — including every grant on a tenant without a budget cap — is omitted (the key is absent when nothing qualifies), because since the fix the model pickers fold these entries into the offered model set (a gateway with only grants and no stored provider key now offers exactly its granted models), and offering a non-routable grant would fail on pick. Grants that don't qualify are still visible raw via GET /gateways/{id}/managed-models (the Add-Model modal reads that, with the set-a-budget hint). Usage of a granted model is served on Myra's managed key and billed to the tenant's metered Myra budget (and can hit quota_exceeded / spend_unverified), unlike a BYOK model of the same provider — the /easy picker badges such rows "Included / billed to budget". The map is folded server-side from the managed_model_grant table (never from the tenant-editable config JSON, so it is not a smuggle vector) on the gateway's resolved config.

💡 Model-offer profile (offer_restricted, offerable_providers). Each row also carries the effective model-offer profile the chat model picker uses so it never surfaces a model that would then be blocked at dispatch. offer_restricted is true when a dispatch gate is armed on the gateway's resolved config (tenant-floor-folded): EU data-residency (eu_region_routing), the provider allowlist (provider_allowlist_enforced), and/or a per-tenant subprocessor objection. When true, offerable_providers lists the only provider names the picker may offer — the subset (of the gateway's configured providers plus the keyless EU fleet myra, plus the provider of any routable managed grant per the managed_models note above) that survives all the armed gates (EU-hosted, on the allowlist, and not objected to). When offer_restricted is false, offerable_providers is an empty array and the picker offers every configured provider unchanged. The values are computed by a single backend authority (reusing the same EU-residency and provider-allowlist predicates the upstream dispatch chokepoint enforces) — so the offered set can never drift from what will actually route. Fail-closed: if the resolved config cannot be loaded, the row is returned with offer_restricted: true and an empty offerable_providers (offer nothing extra) rather than falling back to the un-folded stored config.

💡 Web-search capability (web_search_configured). Each row of this listing and the single-gateway GET /gateways/{id} carries a computed boolean web_search_configured — the server's OWN web-search availability verdict for the gateway (search.gateway_configured: the config.web_search block is present, enabled is truthy, AND api_key is a non-empty string), the exact predicate the dispatch path enforces on. It is computed on the raw config and present for every reader (including one whose web_search.api_key is redacted per the credential-scoping rule above). The /easy composer web-search globe and the model-picker "Web research" capability tag read this boolean so they reflect real capability without the client reading the provider api_key; on a gateway where it is false the globe is shown disabled-with-reason and no model is tagged "Web research", and a turn that still requests search (x-aig-web-search) is skipped server-side and logged (operator WARN) rather than silently answering ungrounded. Absent from an older backend ⇒ the client treats the gateway as not search-capable.

💡 Web-search setup-pending (web_search_setup_pending). A companion boolean on the same two read routes, computed by search.search_setup_pending on the gateway's resolved config: it is true when web search is enabled on the gateway (the config.web_search block is present and enabled is truthy) but there is no usable api_key yet — the "enabled, not set up" state, distinct from web_search_configured: false meaning search is simply not enabled. This is the exact state every newly created production gateway (any plan — AGF-2874) sits in while the platform Linkup key (the DB settings row trial_linkup_api_key, super-admin-set in the admin console → Feature Flags) is unset: core.gateway_defaults.seed_web_search seeds a keyless Linkup block at creation, and core.config._apply_platform_web_search_key injects nothing until the setting is filled (the Health dashboard then reports web_search_platform_key_missing: true). The two booleans are mutually exclusive — the server derives pending as not web_search_configured and search_setup_pending, so a gateway is never both — giving the client a tri-state: configured (globe works), pending (globe disabled + a visible "not set up" notice in the /easy composer), or neither (search not enabled — globe disabled, no notice). Same raw-default-then-refine-on-resolved computation as web_search_configured, so a resolve fault on a trial keyless row still reports pending (the informative notice) rather than the silent not-configured state. A malformed (cjson.null) enabled degrades to not pending (the silent state), never to a spurious notice (invariant 11: ABSENT and MALFORMED must not become PENDING). Absent from an older backend ⇒ the client treats the gateway as not pending (no notice).

💡 Note: In addition to the purpose filter, the default listing rejects rows whose slug matches a known test-fixture naming heuristic (prefixes such as e2e-, test-, sim-, or slugs embedding a millisecond timestamp). This second layer exists because some fixtures must be purpose: "production" to act as routing targets and would otherwise leak into user-facing pickers. Accounts whose e-mail address ends in a test TLD (.test, .local, …) — and any caller passing ?include_test=1 — bypass both filters and see every gateway.

Creating a gateway

curl -X POST https://<your-gateway-host>/admin/v1/tenants/{tenant_id}/gateways \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "production",
    "config": {
      "auth_required": true,
      "cache_ttl": 300,
      "retry_count": 2,
      "timeout_ms": 30000,
      "log_payloads": true,
      "budget_usd": 500.00,
      "rate_limit": {"requests": 100, "window_sec": 60}
    }
  }'
Field Type Required Description
slug string Yes URL-safe identifier unique within the tenant. Must be a non-empty string of at most 255 characters — a null, a number, a boolean or an object is rejected 400 and nothing is persisted. Posting an existing slug updates that gateway (upsert).
config object No Gateway configuration. Omitted or null on an existing gateway leaves the stored config untouched (see the note above); on a new gateway it starts as {} — plus, for a new production gateway, the seeded web-search default (see the note below). When supplied, it replaces the stored config — with one platform-admin-only exception: config.test_headers_allowed. On a write by any actor below platform admin its submitted value is ignored and the stored value is carried into the replaced config (so a scripted upsert that omits it does not disarm a gateway, and one that sends it does not arm it); the 201 then carries "ignored_fields": ["test_headers_allowed"] when the dropped value differed. This field is strip-and-carry rather than the 403-on-change rule the tenant's platform-admin-only fields use precisely because this route replaces the whole config: an omitted key here is a change, and a 403 would refuse every upsert that does not echo the flag. A platform admin's non-boolean value that differs from the stored one is 400. A transient fault on the read of the existing gateway's config returns 503 ("gateway lookup temporarily unavailable; retry") and writes nothing — the write depends on that read.
purpose string No Visibility tier: "production", "test", "benchmark", or "archived". Omitting it leaves an existing gateway's purpose untouched and defaults a new one to "production". An unrecognised value is rejected 400 (it used to be silently ignored, which turned a typo such as "archive" into a gateway that routes production traffic). Only "production" gateways appear in user-facing routing and the default tenant listing.
create_only boolean No Declares create-intent. With true, posting an existing slug is refused 409 { "error": "…", "code": "slug_taken" } and nothing is written — the stored config (guardrails included) is left exactly as it is; no id is echoed on the refusal. Omitted, null or false keeps the documented upsert for scripted callers. A non-boolean value ("true", 1, an object) is rejected 400 — a string "false" must never be read as a flag. The dashboard's Create gateway dialog always sends true: a dialog titled Create cannot act as an overwrite of a gateway the operator believes they are creating. A concurrent create that loses the race on the slug's unique key is reported as the same 409, never a 500.

Response: { "id": "...", "slug": "...", "purpose": "production" } — purpose is the value the gateway now has, which for an upsert that did not set one is the purpose it already carried. An optional "ignored_fields": ["test_headers_allowed"] is present only when a non-platform-admin's differing value for that field was dropped (see the config row).

🌐 Web search is seeded on every new production gateway (AGF-2874). When the slug is new and the effective purpose is production (given or defaulted), the stored config additionally receives web_search: { "enabled": true, "provider": "linkup", "max_results": 5 } — a keyless Linkup block — only if the supplied config has no web_search key at all. A supplied web_search (your own api_key, another provider such as brave, an explicit { "enabled": false }, or even JSON null) is written verbatim — BYOK always takes precedence and is never overwritten. New test / benchmark / archived gateways are not seeded (their config is stored exactly as posted). The block carries no key: the Myra platform Linkup key (the trial_linkup_api_key setting — all plans) is injected at read time by the inference path, so the GET/list responses and the tenant export show the block without an api_key for every caller, including GATEWAYS_MANAGE, while web_search_configured reports true once the platform key is set. image_generation is not seeded on this path (self-signup only). The upsert of an existing slug never seeds anything. Seeding happens inside the single storage writer, so no create request can bypass it.

🔒 Gateway budget shape. When config.budget_usd is set, it must be a non-negative number — a negative / NaN / +inf / non-number value is rejected 400 ("budget_usd must be a non-negative number") and nothing is persisted, at both the create route and the PATCH /gateways/{id} route (the PATCH re-validates the merged value when the body touches the cap — budget_usd or budget_period — so a legacy row carrying a pre-validation malformed budget is caught rather than silently re-persisted; an edit that touches neither is not blocked). A malformed budget would otherwise brick the gateway (a negative cap blocks its first request) or silently make it unlimited (NaN reads as no cap) at runtime. 0 is a legitimate hard-freeze; empty/null is unlimited. A gateway budget above the tenant's own budget_usd is no longer rejected (the former over-cap 400 was removed — PO decision 2026-09-25): it is accepted and the gateway Edit UI shows a non-blocking informational warning ("this gateway's budget is higher than the organisation budget; the organisation budget will apply first"). middleware/quota enforces the per-tenant and per-gateway caps independently, so the organisation budget always binds first regardless of a larger (and thus meaningless) gateway sub-cap — the over-cap value is a display concern, not a spend risk.

💡 Note: purpose classifies a gateway's visibility tier (migration 0048). Only production gateways take part in user-facing routing and appear in the default GET /tenants/{id}/gateways listing. test and benchmark gateways (E2E fixtures and benchmark rigs) and archived gateways are hidden from that listing unless the caller passes ?include_test=1; they are never offered to the chat composer. archived marks a soft-retired gateway kept for historical conversation attribution but never routed to.

Getting a gateway

curl https://<your-gateway-host>/admin/v1/gateways/{id}

The response carries the gateway row, including purpose (it was previously omitted here and available only from the tenant listing). purpose is the routing authority: only production gateways route, so a client that must know whether a gateway can serve a conversation reads this field rather than guessing from the slug.

🔒 Guardrail term/pattern lists are scoped to GATEWAYS_MANAGE. GET /gateways/{id} and the GET /tenants/{id}/gateways listing are gated by gateway/tenant access (any member of the tenant), but the returned config.guardrails (and the legacy config.detectors array and every role_policies[*].guardrails) can hold admin-authored term/pattern lists — a custom_pii / keyword / jailbreak detector's keywords, and a regex detector's custom_patterns — which may encode sensitive client codenames or an evasion-worthy block list. These value lists are returned in full only to a caller holding GATEWAYS_MANAGE. A caller with access but not GATEWAYS_MANAGE receives each detector with its keywords / custom_patterns replaced by an empty array (the detector's type/name/action/target/enabled stay visible, matching GET /gateways/{id}/detectors). This is a server-side projection — the stored config is unchanged, and the write path (PATCH /gateways/{id}) already requires GATEWAYS_MANAGE. Masking exemption lists (presidio / pii_protector allow_list), a json_schema schema, and a prompt_guard context_prompt are a lower-risk class and remain visible.

🔑 Third-party credential leaves are scoped to GATEWAYS_MANAGE. On the same two access-gated read routes, config also embeds live secrets. A caller with access but not GATEWAYS_MANAGE (a plain member, a viewer, or the no-login demouser) receives the config with these withheld: web_search.api_key, semantic_cache.embedding_api_key, webhooks.secret, the entire siem block (Splunk HEC token / Basic credentials), and tracing.headers (OTLP auth headers). These are examples of a name-convention rule, not a fixed list: the shared scrubber (utils.secret_keys, the same one the tenant export uses) drops any key — at any nesting depth — named exactly api_key/apikey/secret/token/password/passwd/credential/credentials/authorization/bearer, or ending _key/_apikey/_secret/_token/_password/_passwd/_credential, plus the whole siem and headers blocks regardless of their inner key names. A newly-added secret-named config field is therefore withheld automatically. Each is returned in full only to a caller holding GATEWAYS_MANAGE — deliberately, so that role's SPA can read-modify-write the config without deleting the stored secret (the PATCH merges at the top level). Non-secret siblings stay visible (web_search.provider/enabled, webhooks.url, semantic_cache.embedding_url, tracing.otlp_endpoint), and the derived web_search_configured and web_search_setup_pending booleans (below) remain present and correct for every reader (both are computed before this redaction), so a lower-privileged UI still shows whether search is configured — or set up but not yet available — without reading the key. This is a server-side projection — the stored config is unchanged.

Updating gateway config

The PATCH body carries a config key (an absent or null config is a harmless no-op that returns 200 { "ok": true }). The config is merged at the top level — only fields you include are changed. Nested objects (rate_limit) are replaced in full.

test_headers_allowed is platform-admin-only. On a PATCH by any actor below platform admin (a tenant admin, or a custom role holding GATEWAYS_MANAGE) the submitted value is ignored and the stored value kept — a tenant admin can neither arm nor disarm the gateway — and the 200 body is { "ok": true, "ignored_fields": ["test_headers_allowed"] } when the dropped value differed from the stored one; omitting the field or re-sending the stored value is a plain { "ok": true }. A platform admin's value is applied; a platform admin's non-boolean value that differs from the stored one is 400. See the field's row in the config reference.

curl -X PATCH https://<your-gateway-host>/admin/v1/gateways/{id} \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "retry_count": 3,
      "timeout_ms": 45000
    }
  }'

To override provider base URLs:

curl -X PATCH https://<your-gateway-host>/admin/v1/gateways/{id} \
  -H "Content-Type: application/json" \
  -d '{
    "config": {
      "provider_base_urls": {
        "openai": "https://my-openai-proxy.internal"
      }
    }
  }'

Accepted / rejected shape. provider_base_urls is validated at the write boundary on both POST and PATCH:

  • Accepted: an object mapping supported provider slugs to non-empty http:// or https:// URLs (a base only — the provider path is appended). null (or an object with no entries) is accepted and clears the override.
  • Rejected with 400: a non-object (scalar / array); an unsupported provider key — one that is not a provider the gateway can route to (… is not a supported provider), or a legacy alias such as vllm, which is rejected with a message naming its canonical id (provider_base_urls['vllm'] is a legacy alias; use 'myra'); a non-string or empty-string value; a URL that is not http(s)://…; a URL carrying userinfo (https://user@host/); or a URL containing whitespace or control bytes. A malformed value is rejected, never silently treated as absent — so a broken override can no longer be saved and then silently fail to route. (An override keyed by a provider the gateway does not route to is dead config: it is looked up at request time by the exact provider name, so an unknown key never routes — hence the rejection. Note: provider_allowlist accepts the vllm alias and folds it to myra; the base-URL map does not, because its key is looked up verbatim.)

This is a shape check only. The full SSRF/security check (host resolution + dial-IP pinning) runs at request time: a saved override that resolves to a private/internal address is still blocked when traffic is sent (configuration_error). Use Test a provider base URL as the pre-save reachability check.

💡 Note: The config merge is shallow. To clear a nested object (e.g. to remove a rate limit or the base-URL overrides), set the field to null explicitly: "rate_limit": null, "provider_base_urls": null.

Test a provider base URL

POST /admin/v1/providers/test-base-url

The pre-save reachability check behind the Test button in Gateway settings › General. Requires GATEWAYS_MANAGE. Rate-limited per actor (20 requests / 60s; over the limit → 429) and audited (provider_base_url.test). The gateway resolves the URL through the same SSRF guard applied to every override at request time — the dial IP is pinned, SNI + Host stay the URL hostname — then makes one short GET and reports the outcome.

curl -X POST https://<your-gateway-host>/admin/v1/providers/test-base-url \
  -H "Content-Type: application/json" \
  -d '{ "provider": "openai", "url": "https://my-openai-proxy.internal" }'
  • 400 — the url is not a well-formed http(s):// base URL (same shape rules as the save boundary above). Nothing is dialed.
  • 200 { "ok": true, "status": <http-status>, "latency_ms": <n> } — the endpoint answered (any HTTP status, e.g. 401/404 from a base path, counts as reachable + TLS-valid).
  • 200 { "ok": false, "reason": "blocked", "detail": "…" } — the URL resolves to a private/internal address or could not be resolved (it would be blocked at request time too).
  • 200 { "ok": false, "reason": "unreachable", "latency_ms": <n>, "detail": "…" } — the connection failed (connect/read error).

🔒 config.siem is platform-admin-only — the second SIEM write door. A gateway's config.siem is an egress target resolved in preference to the tenant's siem, so it carries the identical SSRF risk and the identical control: a non-platform actor (a tenant_admin with GATEWAYS_MANAGE) that changes config.siem is rejected 403 { "error": "siem is platform-admin-only and cannot be changed by a tenant admin" }, on both PATCH /gateways/{id} and POST /tenants/{id}/gateways, before anything is written. An unchanged echo of the stored config.siem (what a GATEWAYS_MANAGE SPA re-sends on a shallow-merge save) is not refused, so routine config saves keep working. When a platform admin sets it, config.siem is validated against the same schema and egress rules as siem (malformed → 400).

EU data-residency config (input validation)

eu_region_routing also has a direct toggle in the admin UI — see Gateway settings › EU routing — in addition to the guardrail-template Target Controls path (which was moved out of being template-only). Both paths write the same field; the floor semantics below apply identically either way.

The residency fields are validated at the trust boundary and fail closed:

  • eu_region_routing — accepted as a JSON boolean. Because it is a security control, a mistyped value ("true", 1, or anything non-boolean) is coerced to a strict boolean at config load and, if unrecognised, treated as false (disabled) with a logged warning — a garbage value never silently enables or bypasses enforcement.
  • bedrock_region / vertex_region / azure_region — region tokens are trimmed and must match ^[a-z0-9-]+$ (≤32 chars); anything with whitespace-in-the-middle, CR/LF, uppercase, /, or control bytes is rejected as unset. Under eu_region_routing, only EU-member regions pass (see Data residency); every other value — including a valid-but-US region or a provider_base_urls override — causes the request to be refused with 403 data_residency_blocked before any upstream call.

Provider allowlist (no silent fallback)

An explicit per-gateway list of approved model providers. It is orthogonal to data residency: residency asks "is this provider/region in the EU?"; the allowlist asks "did the operator approve this provider at all?" — a provider can be EU-hosted yet still not approved for a given gateway. Enforced at the single upstream dispatch chokepoint, so no attempt — primary, fallback, tool-loop leg, summarization, or agentic fetch — can silently route to a provider outside the list.

  • provider_allowlist_enforced — a JSON boolean. Like eu_region_routing, it is a security control: a mistyped value ("true", 1, or anything non-boolean) is coerced to a strict boolean at config load and, if unrecognised, treated as false (not enforced) with a logged warning. Default off — every existing gateway is unaffected. Enforcement can also be turned on deployment-wide by your operator (the sovereign-stack backstop): when it is, every gateway must carry a valid allowlist or its inference fails closed, regardless of per-gateway configuration.
  • provider_allowlist — a JSON array of provider names (e.g. ["myra", "mistral"]). Names are matched case-insensitively after trimming surrounding whitespace, and vllm folds to myra. Accepted: a non-empty array of strings; a requested model whose resolved provider is a member is dispatched. Rejected (fails closed — 403 provider_not_allowed, before any upstream call): a resolved provider not in the array; and, when enforcement is on, a missing / empty ([]) / malformed (non-array) list denies every provider (a forgotten or typo'd list can never silently fall back to allow-all). Non-string array elements (numbers, null, nested objects) are ignored and can never widen the set; a near-miss or substring of an approved name ("open" for openai) never matches. When enforcement is off the list is not consulted.

Scope: the allowlist governs routed chat/inference dispatch. Fixed EU-infrastructure services that call the Myra on-prem fleet directly — embeddings (RAG), image generation/editing, transcription, and vision image-analysis — always use that sovereign fleet and are not routable providers, so they are not gated by this list.

Tenant-level residency default (inherited by every gateway)

eu_region_routing, provider_allowlist_enforced, and provider_allowlist can also be set once at the tenant level so that every gateway under the tenant — including a newly created gateway with an empty config — inherits the control, fail-closed.

eu_region_routing is accepted on PATCH /admin/v1/tenants/{id}:

Field Shape Notes
eu_region_routing 0 \| 1 \| null The organisation-wide EU-routing floor. 1 arms it (every gateway of this organisation is EU-only regardless of its own setting); 0 records an explicit "not enforced"; null clears the column back to "no organisation default", which is a distinct third state. Platform admins only — the field is ignored for any other caller, because disarming it lowers the residency floor the organisation's compliance obligation encodes. Strictly typed: only a real JSON number 0/1 or null is accepted; a string, a boolean, or any other number is ignored rather than coerced. Audited as tenant.eu_region_routing_changed, and the gateway-config cache is flushed for every gateway of the tenant so the change takes effect immediately rather than after the cache TTL.
how a sovereign tenant gets "EU-region routing default ON" on a shared stack without
a deployment-wide setting (which would affect every tenant). See
Tenant-level residency default.
  • DB/migration only: the tenant defaults live in tenant columns and are not exposed as an admin API / PATCH field — set them via a migration or a db.sh UPDATE. When writing tenant.provider_allowlist, mind the quoting: the value must be SQL NULL or a valid JSON array ('["myra","mistral"]' — the entry quotes are part of the stored bytes). The database rejects anything else (CHECK constraint chk_tenant_provider_allowlist_json, migration 0190): a mis-quoted bare-token '[myra,mistral]', a JSON object/scalar, or a literal 'null' all fail the write instead of silently bricking the tenant (a malformed stored list fails closed — deny-all — while enforced). A pre-constraint row that still carries a non-array value is treated as absent and logged at ERROR level with the tenant slug.
  • Precedence — a FLOOR: when a tenant enables a residency enforcement control (eu_region_routing / provider_allowlist_enforced) it is a floor — a gateway can only raise it, never lower it. A gateway false/0/"off" under a EU-only tenant is still enforced (it cannot opt a sovereign tenant out of EU-only routing). The gateway may be stricter, and a gateway's own value governs only when the tenant default is a recognized OFF. A garbage tenant value fails closed to ENFORCED. For a residency control an absent/ null per-gateway value resolves to the tenant default (the safe ON under a floor).
  • Admin view caveat: gateway GET/PATCH returns the raw per-gateway config, so an inheriting gateway shows these keys absent even though enforcement is active at runtime — read the tenant default to know the effective policy.

Deleting a gateway

curl -X DELETE https://<your-gateway-host>/admin/v1/gateways/{id}

Response 200 { "ok": true }. The delete is one transaction: the gateway row goes and the database cascade takes what hangs off it — API keys and their revocation tombstones, provider keys, routing rules, conversations, agents, schedules, workflows — and the gateway's own spend ledger rows (the gateway scope and its current API keys' token scope, all periods) are swept in the same transaction. A key deleted or revoked before the gateway leaves its own token-scope rows behind (the key's id is gone from the table by then) — the boot pass below reaps those. The tenant's and its users' spend rows are untouched. The delete locks the gateway row before it reads the key ids, so a key minted on this site while the delete runs is either swept with the rest or refused (its insert waits for the lock and fails once the gateway is gone) — never cascaded and left in the ledger. Rows can still appear afterwards: a request that was still running when the gateway was deleted books its cost after it finishes, and on a multi-site deployment a sibling site keeps accepting the gateway's API keys for its configuration / key cache lifetime (minutes) and books those requests too. Those rows are reporting noise, never an enforcement input. At every boot the gateway reaps ledger rows whose gateway or API key no longer exists — the rows stranded by deletes that ran before this behaviour, and the late rows above — in bounded batches on one worker, off the request path (the log line [ledger_orphan_sweep] pass done gateway_rows=N token_rows=M; a healthy fleet reads 0 0). The delete itself does not depend on the boot pass.

Requires the GATEWAYS_MANAGE permission (held by a tenant admin by default, and any custom role granted it) plus ownership of the gateway.

Rejected: 403 forbidden (not your gateway, or the tenant is on the free-trial plan — the two fixed trial gateways cannot be deleted until you upgrade to a paid plan); 404 not found (no such gateway — also when it vanished between the access check and the delete: nothing changed); 503 temporarily unavailable; retry later when the delete kept colliding with a concurrent change to the same API keys (a revoke, an edit, a new key) after its retries, or at once when another writer has held a row the delete needs for the database's lock-wait timeout (another delete of the same gateway in progress — the retry then finds a 404; a request still persisting a turn on this gateway) — nothing changed, retry; 500 internal error on any other database fault, rolled back — the gateway and its ledger rows stay together, and the gateway log carries the cause (if the COMMIT's answer was lost on the wire the delete may have landed; a retry then answers 404).

Bounds. The delete runs under a 120 s statement timeout (not the 10 s the rest of the API uses) because the cascade of a busy chat gateway can take a while; a cascade that outlasts even that is rolled back and answered 500 — nothing is half-deleted; contact support for a gateway that large. A client that disconnects while the delete runs does not interrupt it: the delete completes and only the response is lost (the next GET answers 404).

Resetting the gateway budget counter

Clears the accumulated spend counter. Requests blocked by quota_exceeded are allowed again (up to the configured budget_usd). The optional ?period= query parameter scopes the reset to one period; without it, every period is reset. A repeated ?period= (a ?period=a&period=b) is rejected 400 — it is not coerced.

🔒 Platform-admin only. Because zeroing the ledger re-opens the per-gateway cap (and, via the tenant reset, the platform-set platform_cap_usd ceiling), only a platform admin may reset spend — a tenant_admin, or a member holding a custom role that grants GATEWAYS_MANAGE/TENANT_SETTINGS_MANAGE, receives 403 Forbidden (the ledger is untouched). This subsumes the earlier free-trial-only lock. Every successful reset writes an audit record (gateway.budget_reset) carrying the actor and the pre-reset per-period amount_micro; a storage fault returns 503 (retryable) or 500, never a silent 200.

curl -X DELETE https://<your-gateway-host>/admin/v1/gateways/{id}/budget
curl -X DELETE 'https://<your-gateway-host>/admin/v1/gateways/{id}/budget?period=2026-05'

⚠️ Caution: Budget resets are immediate and irreversible. Automate monthly resets with a cron job rather than resetting manually.

Reading gateway spend history

curl 'https://<your-gateway-host>/admin/v1/gateways/{id}/spend?limit=12'

Returns the most recent limit rows (default 12) of recorded spend, with amount_micro (raw integer micro-USD) and amount_usd (decimal) per period.

Reading gateway budget status

curl 'https://<your-gateway-host>/admin/v1/gateways/{id}/budget'

Returns the budget-focus data for the gateway's current period (the period type is the gateway's own budget_period, default monthly):

{
  "cap_usd": 500.0,
  "spent_usd": 342.15,
  "period": "2026-08",
  "period_type": "monthly",
  "by_model": [
    { "provider": "openai", "model": "gpt-4o", "spent_usd": 210.0 },
    { "provider": "anthropic", "model": "claude-sonnet-4-6", "spent_usd": 98.4 }
  ]
}
  • Access: the same fail-closed require_gateway_access gate as GET /spend — 401 unauthenticated, 404 for an unknown id, 403 for a caller outside the gateway's tenant. The only input is the path id; there is no request body.
  • cap_usd: the gateway's budget_usd cap. null = uncapped (unlimited); 0 is a real zero cap (any spend is over budget). The caller computes the percentage and must guard the zero-cap divide.
  • spent_usd: the authoritative current-period spend from the spend ledger (the same number budget enforcement reads). null when the spend read is degraded — treat as unknown, never 0.
  • by_model: an approximate per-model breakdown from the cost legs (request_log_legs), highest-cost first; [] when there is no billable model spend this period. It includes BYOK legs (the ledger meters them too) and excludes non-billable legs (free local models, non-LLM sidecars). Because the rows and the authoritative total are computed two different ways (per-request micro-floor vs SUM(cost_usd)), the rows do not sum to spent_usd to the cent — the hero total stays authoritative.

Provider keys (BYOK) on a gateway

# List
curl https://<your-gateway-host>/admin/v1/gateways/{id}/keys

# Store / rotate
curl -X POST https://<your-gateway-host>/admin/v1/gateways/{id}/keys \
  -H "Content-Type: application/json" \
  -d '{"provider": "openai", "alias": "default", "key": "sk-..."}'

# Delete
curl -X DELETE https://<your-gateway-host>/admin/v1/gateways/{id}/keys/{provider}/{alias}

The alias defaults to "default". Posting again with the same (provider, alias) rotates the stored key; the previous value is replaced. The stored key is never returned by the list endpoint. A rotation takes effect on the next dispatch on the node that served the rotation (the decrypted-key cache is primed on write); any other node picks up the new key within the cache TTL (≤60s). If an invalid key is stored, the upstream provider's 401 is surfaced to the caller verbatim (a typed { "error": … } body with the upstream status), never a 500, and the key value is never echoed back.

Deleting (revoking) a key takes effect symmetrically: the DELETE evicts the decrypted-key cache on the node that served it, so the next dispatch on that node no longer presents the revoked key (any other node stops within the ≤60s TTL). Revoking a key that a gateway still routes on makes that gateway fall back to its remaining configured credential, or return 424 provider_key_missing if none remains.

Myra-provided (keyless) models on a gateway

A gateway can enable a Myra-provided external model — an Anthropic model served on Myra's own managed key, with no tenant API key. Usage is billed to the tenant's budget_usd (the same cap the trial phase uses). This is the second path of the "Add model" modal.

# The server-side SUPPORTED allowlist (the only models that may be enabled)
curl https://<your-gateway-host>/admin/v1/managed-models/catalog

# List this gateway's enabled Myra-provided models
curl https://<your-gateway-host>/admin/v1/gateways/{id}/managed-models

# Enable one (no API key). Tenant-admin, own gateway only.
curl -X POST https://<your-gateway-host>/admin/v1/gateways/{id}/managed-models \
  -d '{"provider": "anthropic", "model": "claude-sonnet-5"}'

# Disable one
curl -X DELETE https://<your-gateway-host>/admin/v1/gateways/{id}/managed-models/{provider}/{model}

Trust boundary + cost guard (fail-closed): - POST accepts only a (provider, model) in the server-side SUPPORTED allowlist (v1: Anthropic claude-sonnet-5, claude-haiku-4-5); anything else — a different Anthropic model, another provider, or missing fields — is rejected 400 and nothing is persisted. A tenant can never route an arbitrary model on Myra's key. Enable is idempotent. - Authz: the GATEWAYS_MANAGE permission (held by a tenant admin by default, and any custom role granted it) + gateway ownership (require_gateway_access) — the caller can only enable models on their own gateway. - Cost guard: a Myra-provided model is offered and routable only while the tenant has an enforceable dollar cap (budget_usd set). With no dollar cap the model is neither offered in the picker nor routable — the gateway refuses to spend Myra's key on an uncapped balance. A time-only trial (trial_ends_at set but no budget_usd) is not a sufficient cap. - Picker visibility: once enabled and the tenant has a dollar cap, the granted model appears in every model picker (/easy, agents, playground, project/preferences default) at model granularity — enabling claude-sonnet-5 offers exactly that model, never the rest of the provider's catalog — even on a gateway with no stored provider keys. On an EU-residency/allowlist-restricted gateway a (US-hosted) granted model stays hidden and unroutable (offer == route). - On the inference path, if the tenant's spend cannot be verified (an authoritative spend read fails) the request is refused 503 spend_unverified (retryable) rather than routed unmetered; at/over the cap it is 429 quota_exceeded. - Cap-overshoot (accepted limitation): the cap check is a soft, per-turn pre-check (the same model the own-key quota uses) — it reads spend once, before the turn, and the ledger is debited after. Concurrent turns (a read-before-any-debit race) or one long tool-loop can therefore overshoot the dollar cap by a bounded amount before the next request is blocked. Every token is still metered to the tenant; the overshoot is a one-burst-per-period overrun, not free spend. For a demo tenant with no payment obligation this is Myra-absorbed loss, and is an accepted risk (decision 2026-08-03); a per-tenant rate_limit bounds the concurrency that drives it. Hard reservation of the pool-key leg was considered and deferred. - A grant is only offered inside the tenant's plan. For a self-serve tenant the granted model must also be in the plan's model allowlist; a model granted outside the tier is not offered anywhere (it would be refused 403 plan_model_not_allowed at send time). For a manual tenant, which has no per-tier allowlist, this term never applies. - A grant is offered only when its model has a billable price (a non-zero input_per_1k or output_per_1k). A live-but-zero-priced catalog row would be served on Myra's key while the usage meters to 0 — the tenant cap would never move — so it is neither offered nor routable, and the inference path refuses it outright. - Grants are for manual/demo tenants. A self-serve tenant needs none: it routes Anthropic on the managed pool automatically (see below). A Myra-provided pick has no cross-vendor failover (the self-serve failover pipeline is self-serve-only); a pool exhaustion surfaces as a provider fault.

Self-serve tenants: the plan's models are routable without a grant

A self-serve tenant (plan = self_serve_*) has no per-gateway provider keys at all — its Anthropic credential is Myra's managed pool key, injected automatically on every request. Its gateways therefore serve the models its plan entitles (plan_config.models_json, surfaced per gateway as the same managed_models field) with no managed_model_grant row and nothing to enable. This applies to the gateway created at signup and to any gateway the tenant creates later.

The offered set is the plan entitlement narrowed by the same terms a grant is narrowed by: the tenant has a dollar cap, the model has a billable price, the managed pool is wired on the deployment, and the gateway's residency/provider-allowlist vouches Anthropic. Those are the same static terms the inference path enforces, so the picker and the Auto pick cannot propose a model that the route side structurally cannot serve.

Allowance and subscription state are not part of that set — they are checked per request, at send time, because they change between one turn and the next. A model can therefore be correctly offered and still be refused: 429 quota_exceeded when the included allowance is spent (402 trial_budget_exhausted on the free trial), 402 subscription_inactive when the subscription has lapsed past its grace period, and 503 spend_unverified when the workspace's spend cannot be read and the gateway refuses to route unmetered on Myra's key. A workspace whose budget_usd has been cleared is refused 500 configuration_error — the gateway will not spend Myra's key against an unenforceable cap.

Two consequences worth knowing:

  • If a plan's models_json is empty, the tier entitles nothing: the model allowlist is fail-closed, so every model is denied — conversation-create returns no_runnable_route and an explicit pick returns 403 plan_model_not_allowed. A plan in that state is logged once at boot (ERR, [plan_config_seed] … EMPTY model entitlement). Fix the row via PUT /admin/v1/plan-config.
  • A model id that is not servable — a deprecated catalog row, or a dated snapshot such as claude-haiku-4-5-20251001 (which the model list deliberately hides in favour of its bare alias) — routes but never appears in a picker. Entitle the bare alias.

Disabling a Myra-hosted local model on a gateway

The Myra-hosted local fleet (myra provider) is enabled by default on every gateway. A tenant admin can disable individual fleet models per gateway from the same Add-Model modal ("Use Platform Key" › Myra models), or via the API:

# The CHAT-capable fleet with per-gateway enable state (the block is enforced on the
# chat dispatch path, so only chat models are toggleable — never whisper/piper/flux/
# bge rows, whose non-chat endpoints dispatch outside it). Disabled rows stay PRESENT
# with enabled:false (so re-enable is one click, never a hunt through raw API).
curl https://<your-gateway-host>/admin/v1/gateways/{id}/local-models
# → [ { "provider": "myra", "model": "qwen3.8-27b", "enabled": true }, … ]

# Disable (hard block). Tenant-admin (GATEWAYS_MANAGE), own gateway only.
curl -X POST https://<your-gateway-host>/admin/v1/gateways/{id}/local-model-blocks \
  -d '{"provider": "myra", "model": "qwen3.8-27b"}'

# Re-enable
curl -X DELETE https://<your-gateway-host>/admin/v1/gateways/{id}/local-model-blocks/myra/qwen3.8-27b

Semantics (hard block, offer == route): a disabled model is neither offered in any model picker nor routable on that gateway. Enforcement is per dispatch attempt (the primary and every fallback/failover re-target, same chokepoint as the deprecation gate), so an operator routing rule or auto-fallback can never carry a turn onto a disabled model — an all-blocked chain returns the typed 403 model_disabled_on_gateway. New agent/project/scheduled-task pins to a disabled model are rejected at save; a pre-existing pin returns the same typed 403 until re-pinned. The attachment auto-upgrade and the 413 switch-model suggestion never name a disabled model. Internal utility legs are governed too (they dispatch through the same chokepoint). A stale SPA that requests a disabled model through server-side route resolution gets the pre-existing generic no-route error (accepted). A sibling gateway without the block still serves the model.

Trust boundary (fail-closed) + storage: POST accepts only a keyless local provider (the vllm alias normalizes to canonical myra — a block under either spelling blocks both) and a model that exists as a live (non-deprecated), chat-capable model_price row; anything else — incl. a non-chat fleet row like whisper-large-v3-turbo — → 400, nothing persisted. DELETE deliberately skips the catalog check (a stale block for a since-deprecated model must stay removable) and is idempotent (200 on an unknown row); POST is idempotent too. Rows live in gateway_local_model_block (cascade-deleted with the gateway); mutations invalidate the gateway config cache, so pickers and routing reflect a toggle immediately. The deny map is surfaced on the tenant-gateways listing as local_model_blocks ({provider: {model: true}}, folded server-side — never from the tenant-editable config JSON).

Fail direction (deliberate asymmetry): on a block-table read error the blocks are treated as absent (fail open — a disabled model transiently reappears rather than the whole local fleet blacking out on a DB blip). The managed-grant fold keeps the opposite, fail-closed direction (a grant spends Myra's real key).


Tenant analytics, spend, and budget

Per-tenant analytics timeseries

curl 'https://<your-gateway-host>/admin/v1/tenants/{id}/analytics?since=<unix_ms>&bucket=1d&n=30'

Bucket sizes: 5m, 15m, 30m, 1h, 6h, 1d. The default bucket is 1d; the default n is 30; the maximum n is 168. Returns { "timeseries": [...], "top_models": [...] }.

Tenant spend history

curl 'https://<your-gateway-host>/admin/v1/tenants/{id}/spend?limit=12'

Resetting the tenant budget counter

🔒 Platform-admin only. Resetting the tenant spend counter re-opens the platform-set platform_cap_usd ceiling (the quota block reads exactly this ledger), so only a platform admin may do it — a tenant_admin (or a member with a custom role granting TENANT_SETTINGS_MANAGE) receives 403 Forbidden, the ledger untouched. Every successful reset is audited (tenant.budget_reset, with the actor and the pre-reset per-period amount_micro); a storage fault returns 503/500, never a silent 200. A repeated ?period= is rejected 400.

curl -X DELETE https://<your-gateway-host>/admin/v1/tenants/{id}/budget
curl -X DELETE 'https://<your-gateway-host>/admin/v1/tenants/{id}/budget?period=2026-05'

Tenant-scoped provider keys

Tenant-scoped keys serve administrative and usage-synchronisation tasks only; they are not consulted on the inference path (a gateway uses its own provider key). Accordingly, POST accepts only administrative providers — currently anthropic-admin (the key used to pull the Anthropic Admin usage feed). A request for any other (real inference) provider is rejected with 400, because such a key would be stored but never read. Requires the TENANT_SETTINGS_MANAGE permission (held by a tenant admin by default, and any custom role granted it).

# List
curl https://<your-gateway-host>/admin/v1/tenants/{id}/keys

# Store (administrative providers only, e.g. anthropic-admin)
curl -X POST https://<your-gateway-host>/admin/v1/tenants/{id}/keys \
  -H "Content-Type: application/json" \
  -d '{"provider": "anthropic-admin", "alias": "default", "key": "sk-ant-admin-..."}'

# Delete
curl -X DELETE https://<your-gateway-host>/admin/v1/tenants/{id}/keys/{provider}/{alias}

Accepted body: provider (must be an administrative provider — anthropic-admin), key (required), alias (optional, default default). Rejected with 400: a missing provider/key, or a provider that is not an administrative provider (for example openai, anthropic). To give a gateway its own provider key, use the gateway-scoped endpoint above.


Gateway config structure

The complete config object with all defaults:

{
  "auth_required": true,
  "budget_usd": null,
  "budget_period": "monthly",
  "tenant_budget_usd": null,
  "tenant_budget_period": "monthly",
  "cache_ttl": 0,
  "retry_count": 2,
  "timeout_ms": 60000,
  "log_payloads": true,
  "rate_limit": null,
  "ip_allowlist": [],
  "guardrails": [],
  "azure_endpoint": null,
  "azure_deployment": null,
  "azure_api_version": "2024-02-01",
  "bedrock_region": "us-east-1",
  "vertex_project": null,
  "vertex_region": "us-central1",
  "provider_base_urls": {},
  "provider_allowlist_enforced": false,
  "provider_allowlist": []
}

For the full field-by-field reference see Gateway Configuration Reference.


See also