Settings & config
The config block that generates the Control Plane Settings UI — tabs, sections, groups and typed fields, with scopes, audiences and feature gates.
Your manifest owns the Settings UI
Operators never edit environment variables to tune your app, and you never hand-build a settings screen. You declare a config block in app.manifest.yaml, and the Control Plane renders it as a live, typed admin UI (Settings v2) — tabs down the side, sections and groups of fields in the body, validation and secret-masking included. It is the declare-a-schema, get-a-UI pattern of Kubernetes CRDs, applied to app config. This is the rule, not a convenience: every operator-tunable setting — a feature flag, a TTL, a retry count, a rotation policy — belongs in the manifest, not in a raw os.environ.get(...). A value only stays code-bootstrap when it is truly needed before config can load (the URL of the Control Plane itself, the manifest path).
The block is a four-level hierarchy. Each level nests the next:
| Level | Key | What it is |
|---|---|---|
| Tab | config.tabs[] | A top-level page in the service's admin UI (e.g. Networking, Infrastructure). Codes are unique within the manifest. |
| Section | tab.sections[] | A card within a tab (e.g. CORS, Rate limits). |
| Group | section.groups[] | A labelled cluster of fields within a section (e.g. Tier limits). Can be collapsible. |
| Field | group.fields[] | The leaf — one editable value with a type, label, and default. |
Tabs, sections, and groups share the core organizing attributes — a code (^[a-z][a-z0-9_]*$), a label, a sort weight, audiences, and depends_on — plus two that are level-specific: icon is tab-only, and help (the descriptive blurb) is on sections and groups, not tabs. Sections, groups, and individual fields can additionally carry a feature_gate that hides them until a plan feature is on (see Settings behind a plan feature below).
config:
title: My Service
description: My Service configuration
tabs:
- code: networking
label: Networking
icon: globe
sort: 20
sections:
- code: rate_limits
label: Rate limits
help: Per-minute HTTP request budgets enforced by this service.
sort: 20
groups:
- code: tiers
label: Tier limits (per minute)
help: Per IP for public tiers, per token for authenticated tiers.
fields:
- code: enabled
type: toggle
label: Enabled
default: true
scope: [platform]
env: RATE_LIMIT_ENABLED
help: Master switch. Disable only for local debugging.
- code: public
type: int
label: Public
default: 60
scope: [platform]
env: RATE_LIMIT_PUBLIC_PER_MIN
help: Unauthenticated routes. Per IP.The field is where the real shape lives
The JSON Schema pins only the three keys every field needs — code, type, label — and then deliberately allows additional properties, because the Control Plane's field model carries far richer metadata that CP validates strictly at publish time. The keys you will actually use:
| Field key | Purpose |
|---|---|
type | The widget + validation. One of the primitives below, or a namespaced ref (platform:image@1). |
label / help | The field's title and helper text in the UI. |
default | The platform default value. |
scope | Where a value may be set: platform, org, app_instance, user, or operator (list form; see below). |
env | The environment variable the running service reads this value as at boot. |
source | Where option lists / dynamic values come from ({kind: static, options: [...]} or a config-source provider). |
sensitive / secret | Mark a value as a secret — CP masks it in admin UIs and never exposes it on public endpoints. |
depends_on | Show the field only when a sibling (or fully-qualified) field has a given value. |
feature_gate | Hide this field unless the named plan feature is on for the org (field-level; see below). |
sort | Order within the group. |
The primitive types: string, text, html (rich text), int, float, bool, toggle, select, multiselect, secret, json, color, url, email, duration, cron, reference, list, key_value, display (read-only). A type may also name a richer widget — a version-pinned control such as platform:image@1 (shipped in the SDK) or app:<code>:<name>@1 (defined by your app). The next section covers the widget catalogue and how to add your own.
sections:
- code: database
label: Database
audiences: [cp]
groups:
- code: pool
label: Connection pool
help: SQLAlchemy pool sizing, multiplied across every backend x replica x per-instance DB.
fields:
- code: dsn
type: secret # masked in admin, omitted from public endpoints
label: Database DSN
scope: [platform, app_instance]
env: DATABASE_URL
help: PostgreSQL connection string. Per-instance value is provisioned automatically.
- code: pool_size
type: int
label: Pool size
default: 5
scope: [platform, app_instance]
env: DB_POOL_SIZE
help: Persistent connections per worker process.Field widgets — built-in controls and your own
Beyond the primitives, a field's type can name a widget: a version-pinned, purpose-built control that renders with its own input UI, matching client- and server-side validation, and secret masking where needed. The SDK ships a catalogue of platform:* widgets, and your app can register its own without touching the SDK. Every widget is a version-pinned reference <namespace>:<name>@<version>:
platform:<name>@<v>— a control shipped in the SDK'sfield-widgetsmodule, available to every app and to the Control Plane.app:<code>:<name>@<v>— a control your app defines and registers at runtime (the escape hatch).
The version pin is mandatory, and a published widget is immutable — a breaking change ships as a new @2, never an edit to @1. An unknown (id, version) fails manifest registration loudly; there is no silent fallback.
The platform catalogue
The platform:*@1 controls the SDK ships today. Declare one as a field type and the control, its validation, and its masking come for free:
| Group | Widgets |
|---|---|
| Contact | platform:email@1, platform:url@1, platform:phone@1 |
| Locale & region | platform:country@1, platform:locale@1, platform:currency@1, platform:timezone@1 |
| Time | platform:datetime@1, platform:cron@1, platform:duration@1, platform:business-hours@1 |
| Secrets & integrations | platform:secret@1, platform:smtp-connection@1, platform:webhook@1, platform:oauth-credentials@1 |
| Branding | platform:color@1, platform:image@1, platform:logo-set@1 |
| Pickers | platform:role-picker@1, platform:user-picker@1, platform:org-picker@1, platform:plan-picker@1 |
fields:
- code: outbound_smtp
type: platform:smtp-connection@1 # a composite secret control (host/port/creds/TLS)
label: Outbound SMTP
scope: [platform, app_instance]
help: Mail relay for this tenant. Credentials are masked in the admin UI.
- code: brand_logo
type: platform:image@1
label: Brand logo
scope: [platform, org]Defining your own control
When a control is specific to your app, declare it under your own namespace and register it at your frontend's boot — no SDK change. A control is three pieces that share one version-pinned id:
- A field-type registry entry — the storage primitive, params schema, and validator — declared in both the Python (
register(...)) and TypeScript (registerFieldType(...)from@egav/app-sdk/field-types) registries so the backend and frontend validate identically. - A React component implementing
PredefinedFieldPropsfrom@egav/app-sdk/field-widgets. Keep it controlled — readvalue/params, push edits viaonChange; a sensitive control renders the mask for an already-set value and only emitsonChangeon a real edit (otherwise saving the section blanks a secret nobody touched). - A type-ref → component registration via
registerPredefinedFieldComponent(...), at boot alongside the field-type registration:
import { registerFieldType } from '@egav/app-sdk/field-types';
import { registerPredefinedFieldComponent } from '@egav/app-sdk/field-widgets';
import { RatingInput } from './controls/RatingInput';
registerFieldType({ id: 'app:claims:rating', version: 1, primitive: 'int' /* paramsSchema, validate */ });
registerPredefinedFieldComponent('app:claims:rating@1', RatingInput); // version-pinned, immutableThe Settings renderer then resolves your control automatically. Platform built-ins are checked first, so an app control can never shadow a platform:* one; re-registering the same ref with a different component throws unless you pass { replace: true }. The Control Plane validates a field's params against the widget's schema on register and re-runs the validator on every write, so a bad value is rejected server-side regardless of the client. Promote a control to platform:* (shipping it in the SDK) once a second app needs it.
The full field grammar
The quick table above lists the keys you reach for daily; a field accepts more. The complete grammar — everything optional beyond code / type / label:
| Attribute | Purpose |
|---|---|
default | Value applied when nothing is stored at any scope. |
help | Markdown helper text — what it does, its units, where to find the value. |
placeholder | A format hint only (09:00, whsec_…), never a sentence. |
suffix | A unit shown after the input (minutes, MB). |
validation | { required, min, max, pattern, min_length, max_length } — enforced on write, server-side. |
sensitive | Masked on read and encrypted at rest (auto-true for secret types). |
write_only | Never returned on any read, not even masked — for values that must only ever be written. |
read_only | Shown for reference but edited elsewhere (a service URL, an OIDC-derived value). Distinct from deprecated. |
deprecated | Kept for compatibility, slated for removal — rendered disabled. |
source | Where a select / reference field's options come from (see below). |
params | Configuration for a platform:<type> widget, validated against its schema on register (e.g. platform:country@1 with params: { restrict_to: [BG, DE, FR] }). |
item_fields | The nested row schema for a list field (see below). |
env | A one-shot seed from an env var on first register (see below). |
tags | Free labels like advanced / experimental for grouping and filtering in the UI. |
A closed set of choices is never a free-textstring— useselect(one) ormultiselect(many) with asource, so an operator can't type a value the code can't handle.
Options and typeaheads — the source system
A select, multiselect, or reference field draws its choices from a source, of one of two kinds:
kind: static— a fixed list of options written in the manifest.kind: provider— options fetched live from a service, so new options appear with no manifest change. Add asearch_fieldand the control becomes a debounced typeahead.
A provider url uses one of three schemes:
| Scheme | Resolves to |
|---|---|
cp://<resource> | a Control Plane resource (orgs, roles, plans, …) |
service://control-plane/<resource> | the Control Plane in-process (no network loopback) |
app://<app>/<resource> | proxied to the app's own GET /v1/{app}/internal/config-sources/{name}?q=…, which returns {items: [{value, label}]} |
- code: default_role
type: reference
label: Default role
source:
kind: provider
provider: { url: cp://roles, value_field: guid, label_field: name, search_field: q }
- code: log_level
type: select
label: Log level
source:
kind: static
options: [debug, info, warning, error]For a whole connection — an SMTP relay, an OAuth app, a webhook — prefer a bundled platform:* widget over a cluster of loose fields: it validates as a unit and masks its own secrets.
Structured and repeatable fields
Two field types hold structure rather than a single scalar:
list— a repeatable sub-form. Give ititem_fields(the same field grammar, one level down) and the UI renders an add/remove table of rows — a list of allowed redirect URIs, a set of IP rules, and so on.key_value— an arbitrary string→string map (custom headers, label sets), rendered as a key/value grid.
- code: redirect_uris
type: list
label: Allowed redirect URIs
item_fields:
- { code: url, type: url, label: URL }
- { code: label, type: string, label: Label }Seeding from an existing env var — env:
env: migrates a value that used to live in os.environ into the manifest without a flag day. On the first manifest register, if no value is stored yet, the field is seeded once from that env var; from then on the Control Plane is the source of truth and the env var is ignored. It is a one-shot seed, not a runtime fallback — the platform never silently re-reads the env var, so config that can't resolve fails loud rather than degrading.
One declared exception: a field marked deployment_seeded: true is owned by the deployment rather than by an operator. For such a field, a later registration whose env value differs from a stored row that is still env-seeded refreshes the stored value from the env var (the Control Plane adopts the deployment's value instead of only reporting drift). Any operator write, or any narrower-scope override, still takes precedence and stops the refresh; a field without deployment_seeded is never refreshed this way, and drift is only logged.
Scope — where a value can be set
scope is a list of the tiers at which a value may be overridden. Resolution is most-specific-wins, so a field scoped [platform, app_instance] reads a platform default unless a specific tenant instance overrides it:
platform— one value for the whole deployment (the operator default).org— an organization may override it for all its instances.app_instance— a single tenant instance overrides it (noisy-neighbour DB pools, per-tenant limits).user— a portal user's own preference, overriding the tiers above for that one person.operator— a CP operator's own preference, the operator-console counterpart touser.
The last two are preference tiers — one person's own override — where platform/org/app_instance are configuration tiers. A field with scope: [platform] is operator-only; a field with scope: [platform, org, app_instance] can be tuned at any config tier, and the service resolves the tightest match at read time. Values flow to the running container as the env the field declares.
Audiences — which front-end sees the tab
audiences (default [cp]) controls which front-end receives a tab, section, or group in its bootstrap payload. The values are cp (the operator console), portal (the tenant admin surface), and website (public-facing config). The rule is most-restrictive-ancestor-wins: a portal field inside a cp-only section is never delivered to the portal. Use this to expose a subset of settings to org admins while keeping infrastructure knobs operator-only.
Settings behind a plan feature
feature_gate: <feature_code> makes part of the settings surface appear only when the org's plan includes that feature — a premium-only integration, an add-on's tuning knobs, a beta toggle. Declare it at whichever level should disappear as a unit:
| Gate on… | Hidden when the feature is off |
|---|---|
| a section | the whole card — every group and field under it |
| a group | just that cluster of fields |
| a field | that single field |
Tabs are never gated — put the gate on a section or lower. The gate cascades, most-specific-wins: a section gate applies to every group and field beneath it, a group gate to its fields, and a field-level gate overrides the group or section it sits in.
Three properties make it safe to design around:
- It is a tenant-tier concern only. Gating hides entries from the
portalandwebsiteaudiences; thecpoperator console and internal service-to-service callers always receive every gated setting, so tightening a plan never blinds an operator to a knob. - It resolves against the org's active plan. CP flattens the org's effective features and keeps a gated entry only when its
feature_gatecode is among them. - It fails closed. If the org's plan can't be resolved, gated entries stay hidden rather than leak a premium surface — the same posture as the runtime feature gate.
Both halves of the bootstrap response are filtered: the gated entry is stripped from the structure the UI renders and from the resolved values it reads, so nothing about a gated setting reaches a tenant who lacks the feature.
Conditional config — depends_on
Where feature_gate reacts to the plan, depends_on reacts to the current form state — it shows an item only when another field holds a given value. field is either a sibling code within the same group/list, or a fully-qualified tab.section.group.field path: reveal the TLS options only when a "Use TLS" toggle is on, or the S3 bucket only when a storage select reads s3. It applies at any level.
Reading settings in your service
A service never re-implements the scope walk — it asks the Control Plane client for a resolved group:
from egav_cp_client import get_section
tokens = await get_section("authn", "security.authentication.tokens")
ttl = tokens["access_token_expire_minutes"] # resolved, typed
branding = await get_section(
"widgets", "branding.theme", scope="org", scope_id=org_guid, redis=redis_client,
)Two read surfaces deliberately return different shapes:
- The service read (internal tier,
x-api-key) returns flat, typed values with plaintext secrets — but only to the app that owns the config. - The admin read (CP operator tier) returns each field decorated with
{value, source, is_default}so an operator sees where a value came from, and masks secrets as"********".write_onlyfields are returned to neither.
Reads are Redis-first (CP write-throughs on save); writes are section-level — a PUT of the whole typed section, never key-by-key.
Who can view and edit
Settings are an operator surface, protected in layers:
- Tier gate — every Settings v2 route requires a CP operator token; service reads use the internal
x-api-keytier. No portal user edits operator settings (a tenant editing their own org-scoped settings from the portal is a separate self-scope path). - Masking by caller — the same field returns plaintext to the owning service and
"********"to the admin UI;write_onlyreturns to neither. Who is asking changes what comes back. - Scope is edit authority — a
[platform]field is a platform operator's to set; anorg-scoped field is editable by whoever administers that org. Narrowingscopeis how you forbid an override. - Per-section permission — restrict a whole section to operators holding a permission with
requires: { resource, action }on it and the sameRequireAuthzon the config route. Hiding a section in the UI is convenience; the route check is the boundary (see Auth & the five gates).
sections:
- code: billing
label: Billing
requires: { resource: billing, action: admin } # only billing-admins see and edit it
groups: [ ... ]Every config capability, at a glance
| You want… | Use |
|---|---|
| A platform-wide knob | scope: [platform] |
| An org overrides a default | scope: [platform, org] — the org value wins |
| One instance overrides | scope: [platform, org, app_instance] |
| Forbid any override | scope: [platform] only |
| Reset an override | DELETE the value at that scope — inheritance takes over |
| A finite choice set | select + source: { kind: static } (never free-text) |
| Live options from a service | source: { kind: provider, provider: { url: app://…, search_field: q } } |
| A whole SMTP / OAuth connection | platform:smtp-connection@1 / platform:oauth-credentials@1 |
| Email / URL / timezone / cron, validated | the matching platform:<type>@1 |
| A secret | type: secret — masked to admin, plaintext to the owning service |
| A value never echoed, even masked | write_only: true |
| Shown but edited elsewhere | read_only: true |
| A repeatable list of rows | type: list + item_fields |
| Show a field only when another is set | depends_on: { field, equals } |
Migrate an os.environ value | env: SOME_VAR (one-shot seed) |
| A setting only some plans get | feature_gate: <code> |
| A setting only some operators edit | section requires + RequireAuthz |
| An app-specific control | register app:<code>:<name>@1 at boot |
| Read a resolved group at runtime | get_section(app, "tab.section.group", scope, scope_id) |
How it reaches the running service
At manifest publish, the Control Plane imports the whole config block into its config_manifests and config_values tables — this replaces the old standalone <service>.manifest.yaml that services used to load from disk. Your service pulls its resolved values from the Control Plane through the CP client (get_section): a value read once at startup is as fresh as that startup, and a value read at the point of use is current on the next read within the client's bounded cache. The platform does not currently signal a running process when an operator saves a change: the on_config_reload lifecycle hook is declared and published, but its automatic dispatch on a settings save is not wired, so a value your code only reads at boot needs a restart (or your own re-read) to change. Secrets declared sensitive/secret are stored and served masked — "********" in admin, omitted entirely in public endpoints.
New operator-tunable settings go here, inconfig— not intospec.config_schema(the older flat key/default list, still valid for simple bootstrap keys) and never as an undeclaredos.environ.get(...). The manifest is what makes a setting typed, validated, reachable from the CP Settings UI, and overridable per org or per instance.