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.
💡
configmust be a JSON object. OnPOST /tenants/{id}/gatewaysandPATCH /gateways/{id}, theconfigfield — when present — must be a JSON object. A scalar (number/string/boolean) or an array is rejected with400 Bad Requestand nothing is persisted. This guarantees a gateway's stored config can never become anull/scalar/array value that would break every later read of the gateway.An omitted
confignever destroys one.POSTto an existing slug is an upsert, so both routes treat an absent field and an explicit JSONnullthe same way — leave the stored config exactly as it is:
configin the bodyexisting gateway new gateway omitted untouched {}nulluntouched {}{}replaced with {}— the explicit wipe{}{ … }replaced on POST, shallow-merged onPATCHstored scalar or array 400, nothing persisted400Emptying 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 answered201.
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) andexternal(Anthropic Haiku only) — with pre-set per-gateway budgets and provider allowlists (see Budgets). Theexternalgateway (the only one that egresses to a third-party model) is also provisioned with a defaultpii-protectPII-masking guardrail (apii_protectordetector, enabled), so personal data is masked before it leaves for the provider from day one;internalgets 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/v1request toexternalthat 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 theself_serve_trialplan the gateway write boundary is locked server-side so the split cannot be collapsed onto real vendor spend: -PATCH /gateways/{id}that touchesbudget_usd,budget_period,provider_allowlist, orprovider_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 anull/garbage value is rejected exactly like a real one. -POST /tenants/{id}/gateways→403 Forbiddenfor 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 Forbiddenif the import would changeprovider_allowlistorprovider_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}/budgetandDELETE /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 theplatform_cap_usdceiling). 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
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_countis a read-only, server-computed count of the tenant's active administrators — users whose role isadminortenant_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 returns0(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 asnull(the whole list still succeeds — the count degrades to "unknown" rather than failing the request), so a client distinguishes a genuine0from an unavailable count. It is redacted for non-editor members.
effective_seat_cap/effective_spend_capare 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; a0spend cap is a platform freeze),null(explicitly unlimited), or absent — foreffective_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 andGET /admin/v1/tenants/{id}(recomputed each read, so a value refreshes after aPATCHthat changes budget/plan — unlikeadmin_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 — seedocs/internal/tenant-cap-classification.md.
Getting a tenant
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/siemare platform-admin-only. APATCHfrom a non-platform-admin (e.g. atenant_admin) that tries to change any of them is rejected with403 { "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). APATCHthat omits them (the normal tenant-admin edit shape) or re-sends the current stored value is a valid200no-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: atenant_adminPATCHthat would change any of the five is rejected403naming the field and writes nothing. The compare is NULL-normalising per field so an idempotent full-object round-trip keeps working — for the retention pairnulland0are both "disabled"; for the two regionsnulland""are both "unset"; forweb_search_provideronlynullis "unset" (""is that field's own400, 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 a200no-op for these columns — no re-write, no audit row, no gateway-cache flush (the tenant'srow_versionstill advances, as on every successfulPATCH). Scope, stated honestly: this guards the tenant's org-wide default columns. The per-gateway region / web-search override onPATCH /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_usdis the one exception: it is NOT in the 403 list above. Atenant_admin's genuine change is permitted, not blocked. Whether it applies immediately (200, audited) or is routed through the four-eyes config-approval gate (202held) depends on the tenant's per-tenantbudget_four_eyes_requiredopt-in (default OFF → immediate). Theplatform_cap_usdceiling 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_versionfromGET /admin/v1/tenants/{id}(or the list) and send it back asexpected_versionon thePATCH. If the tenant changed under you, thePATCHreturns409 { "error": "stale", "row_version": <current> }and writes nothing — reload and retry. On success thePATCHreturns the bumpedrow_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 successfulPATCHadvancesrow_version— except a four-eyes202held response, which returns before the transaction and therefore leaves the caller's token still valid — including one that omitsexpected_version— so a caller that does send the precondition always detects a concurrent write, whatever the other caller did.expected_versionis therefore opt-in: omit it and your own request keeps the legacy last-writer-wins behaviour (no409), but it still bumps the version and cannot silently slip past a concurrent precondition-checking editor. This mirrors the workflow-draftupdated_atprecondition.⚛️ Atomicity — a
PATCHapplies all-or-nothing. All of aPATCH's field writes and therow_versionbump are applied inside a single database transaction. If any write fails mid-request, the whole edit is rolled back: the tenant row — including itsrow_version— is left exactly as it was before the request, and thePATCHreturns500. Nothing is left half-applied, so a retry sees the unchanged row and does not spuriously409on a phantom version bump, and a concurrentGETcan 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
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 (roletenant_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 grantsTENANT_SETTINGS_MANAGE— deleting the organisation is not a delegable capability. A same-tenantmember/viewer(even one holding a delegatedTENANT_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_atstaysNULL). 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 newrequest_log/request_log_legsrows after decommission that a subsequent purge could miss.
Restoring a soft-deleted tenant
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 delegatedTENANT_SETTINGS_MANAGE, → 403; unauthenticated → 401. Restore is a Myra-operations recovery lever (strictly higher than the soft delete's owntenant_adminarm) — in particular it is how atenant_adminwho 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_atwrite 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). Atenant_admin,ki_manager, or member → 403; unauthenticated → 401. (Strictly higher than the soft delete, which atenant_adminmay 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_atset). 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_ipis nulled) rather than deleted, preserving the Nachvollziehbarkeit (traceability) trail; the shared operationalmodel_errortriage 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
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).
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 (notenant.purgedrecord).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.POSTrequires the key to have ≥ 1 provider mapping — else400 no_mapping(an objection must deactivate at least one concrete provider; it is never a silent no-op). Idempotent on(tenant, key).DELETEof 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
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
productiongateways. Pass?include_test=1to also includetest,benchmark, andarchivedgateways (used by the admin Gateways page that manages fixtures). Each returned row carries itspurposefield and aconfigured_providersarray listing the providers that have a key configured on the gateway.💡 Myra-provided models (
managed_models). A row carriesmanaged_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 viaGET /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 hitquota_exceeded/spend_unverified), unlike a BYOK model of the same provider — the/easypicker badges such rows "Included / billed to budget". The map is folded server-side from themanaged_model_granttable (never from the tenant-editableconfigJSON, 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_restrictedistruewhen 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. Whentrue,offerable_providerslists the only provider names the picker may offer — the subset (of the gateway's configured providers plus the keyless EU fleetmyra, plus the provider of any routable managed grant per themanaged_modelsnote above) that survives all the armed gates (EU-hosted, on the allowlist, and not objected to). Whenoffer_restrictedisfalse,offerable_providersis 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 withoffer_restricted: trueand an emptyofferable_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-gatewayGET /gateways/{id}carries a computed booleanweb_search_configured— the server's OWN web-search availability verdict for the gateway (search.gateway_configured: theconfig.web_searchblock is present,enabledis truthy, ANDapi_keyis 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 whoseweb_search.api_keyis redacted per the credential-scoping rule above). The/easycomposer web-search globe and the model-picker "Web research" capability tag read this boolean so they reflect real capability without the client reading the providerapi_key; on a gateway where it isfalsethe 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 bysearch.search_setup_pendingon the gateway's resolved config: it istruewhen web search is enabled on the gateway (theconfig.web_searchblock is present andenabledis truthy) but there is no usableapi_keyyet — the "enabled, not set up" state, distinct fromweb_search_configured: falsemeaning 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 DBsettingsrowtrial_linkup_api_key, super-admin-set in the admin console → Feature Flags) is unset:core.gateway_defaults.seed_web_searchseeds a keyless Linkup block at creation, andcore.config._apply_platform_web_search_keyinjects nothing until the setting is filled (the Health dashboard then reportsweb_search_platform_key_missing: true). The two booleans are mutually exclusive — the server derives pending asnot 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/easycomposer), or neither (search not enabled — globe disabled, no notice). Same raw-default-then-refine-on-resolved computation asweb_search_configured, so a resolve fault on a trial keyless row still reportspending(the informative notice) rather than the silent not-configured state. A malformed (cjson.null)enableddegrades 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
purposefilter, the default listing rejects rows whose slug matches a known test-fixture naming heuristic (prefixes such ase2e-,test-,sim-, or slugs embedding a millisecond timestamp). This second layer exists because some fixtures must bepurpose: "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
purposeisproduction(given or defaulted), the stored config additionally receivesweb_search: { "enabled": true, "provider": "linkup", "max_results": 5 }— a keyless Linkup block — only if the suppliedconfighas noweb_searchkey at all. A suppliedweb_search(your ownapi_key, another provider such asbrave, an explicit{ "enabled": false }, or even JSONnull) is written verbatim — BYOK always takes precedence and is never overwritten. Newtest/benchmark/archivedgateways are not seeded (their config is stored exactly as posted). The block carries no key: the Myra platform Linkup key (thetrial_linkup_api_keysetting — all plans) is injected at read time by the inference path, so theGET/list responses and the tenant export show the block without anapi_keyfor every caller, includingGATEWAYS_MANAGE, whileweb_search_configuredreportstrueonce the platform key is set.image_generationis 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_usdis set, it must be a non-negative number — a negative /NaN/+inf/ non-number value is rejected400("budget_usd must be a non-negative number") and nothing is persisted, at both the create route and thePATCH /gateways/{id}route (thePATCHre-validates the merged value when the body touches the cap —budget_usdorbudget_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 (NaNreads as no cap) at runtime.0is a legitimate hard-freeze; empty/nullis unlimited. A gateway budget above the tenant's ownbudget_usdis no longer rejected (the former over-cap400was 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/quotaenforces 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:
purposeclassifies a gateway's visibility tier (migration 0048). Onlyproductiongateways take part in user-facing routing and appear in the defaultGET /tenants/{id}/gatewayslisting.testandbenchmarkgateways (E2E fixtures and benchmark rigs) andarchivedgateways are hidden from that listing unless the caller passes?include_test=1; they are never offered to the chat composer.archivedmarks a soft-retired gateway kept for historical conversation attribution but never routed to.
Getting a gateway
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 theGET /tenants/{id}/gatewayslisting are gated by gateway/tenant access (any member of the tenant), but the returnedconfig.guardrails(and the legacyconfig.detectorsarray and everyrole_policies[*].guardrails) can hold admin-authored term/pattern lists — acustom_pii/keyword/jailbreakdetector'skeywords, and aregexdetector'scustom_patterns— which may encode sensitive client codenames or an evasion-worthy block list. These value lists are returned in full only to a caller holdingGATEWAYS_MANAGE. A caller with access but notGATEWAYS_MANAGEreceives each detector with itskeywords/custom_patternsreplaced by an empty array (the detector'stype/name/action/target/enabledstay visible, matchingGET /gateways/{id}/detectors). This is a server-side projection — the stored config is unchanged, and the write path (PATCH /gateways/{id}) already requiresGATEWAYS_MANAGE. Masking exemption lists (presidio/pii_protectorallow_list), ajson_schemaschema, and aprompt_guardcontext_promptare a lower-risk class and remain visible.🔑 Third-party credential leaves are scoped to
GATEWAYS_MANAGE. On the same two access-gated read routes,configalso embeds live secrets. A caller with access but notGATEWAYS_MANAGE(a plain member, aviewer, or the no-logindemouser) receives the config with these withheld:web_search.api_key,semantic_cache.embedding_api_key,webhooks.secret, the entiresiemblock (Splunk HEC token / Basic credentials), andtracing.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 exactlyapi_key/apikey/secret/token/password/passwd/credential/credentials/authorization/bearer, or ending_key/_apikey/_secret/_token/_password/_passwd/_credential, plus the wholesiemandheadersblocks 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 holdingGATEWAYS_MANAGE— deliberately, so that role's SPA can read-modify-write the config without deleting the stored secret (thePATCHmerges at the top level). Non-secret siblings stay visible (web_search.provider/enabled,webhooks.url,semantic_cache.embedding_url,tracing.otlp_endpoint), and the derivedweb_search_configuredandweb_search_setup_pendingbooleans (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://orhttps://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 asvllm, 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 nothttp(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_allowlistaccepts thevllmalias and folds it tomyra; 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
configmerge is shallow. To clear a nested object (e.g. to remove a rate limit or the base-URL overrides), set the field tonullexplicitly:"rate_limit": null,"provider_base_urls": null.
Test a provider 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— theurlis not a well-formedhttp(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/404from 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.siemis platform-admin-only — the second SIEM write door. A gateway'sconfig.siemis an egress target resolved in preference to the tenant'ssiem, so it carries the identical SSRF risk and the identical control: a non-platform actor (atenant_adminwithGATEWAYS_MANAGE) that changesconfig.siemis rejected403 { "error": "siem is platform-admin-only and cannot be changed by a tenant admin" }, on bothPATCH /gateways/{id}andPOST /tenants/{id}/gateways, before anything is written. An unchanged echo of the storedconfig.siem(what aGATEWAYS_MANAGESPA re-sends on a shallow-merge save) is not refused, so routine config saves keep working. When a platform admin sets it,config.siemis validated against the same schema and egress rules assiem(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 asfalse(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. Undereu_region_routing, only EU-member regions pass (see Data residency); every other value — including a valid-but-US region or aprovider_base_urlsoverride — causes the request to be refused with403 data_residency_blockedbefore 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. Likeeu_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 asfalse(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, andvllmfolds tomyra. 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"foropenai) 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.shUPDATE. When writingtenant.provider_allowlist, mind the quoting: the value must be SQLNULLor a valid JSON array ('["myra","mistral"]'— the entry quotes are part of the stored bytes). The database rejects anything else (CHECKconstraintchk_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 gatewayfalse/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/nullper-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
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_usdceiling), only a platform admin may reset spend — atenant_admin, or a member holding a custom role that grantsGATEWAYS_MANAGE/TENANT_SETTINGS_MANAGE, receives403 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-periodamount_micro; a storage fault returns503(retryable) or500, never a silent200.
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
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
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_accessgate asGET /spend—401unauthenticated,404for an unknown id,403for a caller outside the gateway's tenant. The only input is the path id; there is no request body. cap_usd: the gateway'sbudget_usdcap.null= uncapped (unlimited);0is 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).nullwhen the spend read is degraded — treat as unknown, never0.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 vsSUM(cost_usd)), the rows do not sum tospent_usdto 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_jsonis empty, the tier entitles nothing: the model allowlist is fail-closed, so every model is denied — conversation-create returnsno_runnable_routeand an explicit pick returns403 plan_model_not_allowed. A plan in that state is logged once at boot (ERR,[plan_config_seed] … EMPTY model entitlement). Fix the row viaPUT /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
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
Resetting the tenant budget counter
🔒 Platform-admin only. Resetting the tenant spend counter re-opens the platform-set
platform_cap_usdceiling (the quota block reads exactly this ledger), so only a platform admin may do it — atenant_admin(or a member with a custom role grantingTENANT_SETTINGS_MANAGE) receives403 Forbidden, the ledger untouched. Every successful reset is audited (tenant.budget_reset, with the actor and the pre-reset per-periodamount_micro); a storage fault returns503/500, never a silent200. A repeated?period=is rejected400.
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.