Skip to main content

Documentation

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:

LevelKeyWhat it is
Tabconfig.tabs[]A top-level page in the service's admin UI (e.g. Networking, Infrastructure). Codes are unique within the manifest.
Sectiontab.sections[]A card within a tab (e.g. CORS, Rate limits).
Groupsection.groups[]A labelled cluster of fields within a section (e.g. Tier limits). Can be collapsible.
Fieldgroup.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).

app.manifest.yamlyaml
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 keyPurpose
typeThe widget + validation. One of the primitives below, or a namespaced ref (platform:image@1).
label / helpThe field's title and helper text in the UI.
defaultThe platform default value.
scopeWhere a value may be set: platform, org, app_instance, user, or operator (list form; see below).
envThe environment variable the running service reads this value as at boot.
sourceWhere option lists / dynamic values come from ({kind: static, options: [...]} or a config-source provider).
sensitive / secretMark a value as a secret — CP masks it in admin UIs and never exposes it on public endpoints.
depends_onShow the field only when a sibling (or fully-qualified) field has a given value.
feature_gateHide this field unless the named plan feature is on for the org (field-level; see below).
sortOrder 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.

app.manifest.yamlyaml
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's field-widgets module, 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:

GroupWidgets
Contactplatform:email@1, platform:url@1, platform:phone@1
Locale & regionplatform:country@1, platform:locale@1, platform:currency@1, platform:timezone@1
Timeplatform:datetime@1, platform:cron@1, platform:duration@1, platform:business-hours@1
Secrets & integrationsplatform:secret@1, platform:smtp-connection@1, platform:webhook@1, platform:oauth-credentials@1
Brandingplatform:color@1, platform:image@1, platform:logo-set@1
Pickersplatform:role-picker@1, platform:user-picker@1, platform:org-picker@1, platform:plan-picker@1
app.manifest.yamlyaml
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:

  1. 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.
  2. A React component implementing PredefinedFieldProps from @egav/app-sdk/field-widgets. Keep it controlled — read value/params, push edits via onChange; a sensitive control renders the mask for an already-set value and only emits onChange on a real edit (otherwise saving the section blanks a secret nobody touched).
  3. A type-ref → component registration via registerPredefinedFieldComponent(...), at boot alongside the field-type registration:
boot.tstsx
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, immutable

The 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:

AttributePurpose
defaultValue applied when nothing is stored at any scope.
helpMarkdown helper text — what it does, its units, where to find the value.
placeholderA format hint only (09:00, whsec_…), never a sentence.
suffixA unit shown after the input (minutes, MB).
validation{ required, min, max, pattern, min_length, max_length } — enforced on write, server-side.
sensitiveMasked on read and encrypted at rest (auto-true for secret types).
write_onlyNever returned on any read, not even masked — for values that must only ever be written.
read_onlyShown for reference but edited elsewhere (a service URL, an OIDC-derived value). Distinct from deprecated.
deprecatedKept for compatibility, slated for removal — rendered disabled.
sourceWhere a select / reference field's options come from (see below).
paramsConfiguration for a platform:<type> widget, validated against its schema on register (e.g. platform:country@1 with params: { restrict_to: [BG, DE, FR] }).
item_fieldsThe nested row schema for a list field (see below).
envA one-shot seed from an env var on first register (see below).
tagsFree labels like advanced / experimental for grouping and filtering in the UI.
A closed set of choices is never a free-text string — use select (one) or multiselect (many) with a source, 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 a search_field and the control becomes a debounced typeahead.

A provider url uses one of three schemes:

SchemeResolves 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}]}
app.manifest.yamlyaml
- 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 it item_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.
app.manifest.yamlyaml
- 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 to user.

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 sectionthe whole card — every group and field under it
a groupjust that cluster of fields
a fieldthat 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 portal and website audiences; the cp operator 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_gate code 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:

read_config.pypython
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_only fields 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-key tier. 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_only returns to neither. Who is asking changes what comes back.
  • Scope is edit authority — a [platform] field is a platform operator's to set; an org-scoped field is editable by whoever administers that org. Narrowing scope is 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 same RequireAuthz on the config route. Hiding a section in the UI is convenience; the route check is the boundary (see Auth & the five gates).
app.manifest.yamlyaml
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 knobscope: [platform]
An org overrides a defaultscope: [platform, org] — the org value wins
One instance overridesscope: [platform, org, app_instance]
Forbid any overridescope: [platform] only
Reset an overrideDELETE the value at that scope — inheritance takes over
A finite choice setselect + source: { kind: static } (never free-text)
Live options from a servicesource: { kind: provider, provider: { url: app://…, search_field: q } }
A whole SMTP / OAuth connectionplatform:smtp-connection@1 / platform:oauth-credentials@1
Email / URL / timezone / cron, validatedthe matching platform:<type>@1
A secrettype: secret — masked to admin, plaintext to the owning service
A value never echoed, even maskedwrite_only: true
Shown but edited elsewhereread_only: true
A repeatable list of rowstype: list + item_fields
Show a field only when another is setdepends_on: { field, equals }
Migrate an os.environ valueenv: SOME_VAR (one-shot seed)
A setting only some plans getfeature_gate: <code>
A setting only some operators editsection requires + RequireAuthz
An app-specific controlregister app:<code>:<name>@1 at boot
Read a resolved group at runtimeget_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, in config — not into spec.config_schema (the older flat key/default list, still valid for simple bootstrap keys) and never as an undeclared os.environ.get(...). The manifest is what makes a setting typed, validated, reachable from the CP Settings UI, and overridable per org or per instance.