Documentation/Building Your App/Anatomy of a Supero App
Anatomy of a Supero App
A Supero app is a directory bundle with a fixed shape: a handful of files you author, and a UI runtime the SDK generates for you on every run. This page maps every file so you know exactly what to edit and what to leave alone.
An app is a directory bundle, not loose files
Every Supero app is installed with the pip install supero package and booted with a run.sh launcher that drives python -m supero.cli. The CLI expects a fixed set of files at exact paths inside your app directory — it does not scan for scripts or infer structure. You author the files below; the CLI materializes the rest (the browser-facing UI runtime) the first time it runs, and regenerates most of it on every subsequent run.
| File | You write it? | Purpose |
|---|---|---|
| schemas.py | Yes | Data model: enums and object schemas, exported as ALL_SCHEMAS and PUBLIC_SCHEMAS. |
| config.py | Yes | A single AppConfig dataclass: app identity, tenants, users, services. |
| setup.py | Yes | AppSetup(...).run(): registers schemas/services/policies/workflows and seeds data. |
| ui_schemas.py OR ui/app.js | Yes — pick one | The UI layer. A declarative UI_SCHEMAS list, or a hand-written front end. Never both. |
| __main__.py | Yes | A 3-line entry point that calls setup.main(). |
| requirements.txt | Yes | One line: supero. |
| run.sh | Yes | Idempotent launcher: creates a venv, installs the SDK, boots the CLI. |
| .env / .env.example | Yes | Credentials and SUPERO_DOMAIN/SUPERO_PROJECT. Ship .env.example only, never a real .env. |
| ui/index.html, ui/config.js, ui/server.py, ui/*.js runtime | No — generated | Materialized by the CLI on first run; see "Runtime-generated files" below. |
| Dockerfile, docker-compose.yml | No — generated once | Written with a working default if missing; a version you commit is left alone. |
ℹ️ Paths are fixed
schemas.py, config.py and setup.py live at the app root. The UI lives under ui/, at ui/app.js if hand-built. Renaming or relocating any of these breaks the CLI's discovery of your bundle.
schemas.py — the data model
schemas.py exports exactly two module-level names: ALL_SCHEMAS, a list of your schema dicts, and PUBLIC_SCHEMAS, the snake_case slugs of the schemas that should be readable without authentication. Nothing about the UI lives here — schemas.py only describes data. The platform turns every object schema into a database-backed table plus a full CRUD REST API at api.supero.dev/api/v1/crud/{domain}/{type}.
The three schema kinds
| schema_type | parent_type required? | Has lifecycle (uuid, CRUD)? | Purpose |
|---|---|---|---|
| object | required | Yes — gets uuid, fq_name, parent_type, parent_uuid, created_at, updated_at | A first-class entity (Product, Order). |
| type | no | No | A reusable, embedded value struct referenced from other schemas (Address, Money). |
| enum | no | No | A fixed set of allowed values (values: [...], base type defaults to string). |
An object schema
python
Product = {
"schema_type": "object",
"name": "Product",
"namespace": "acmestore", # same literal string on every schema dict in this app
"parent_type": "tenant",
"description": "A purchasable item with price, stock and media.",
"attributes": [
{"name": "title", "type": "string", "mandatory": True},
{"name": "price", "type": "float", "mandatory": True},
# bare "status"/"state" is stripped from non-admin writes — use a "*_state" name
{"name": "product_state", "type": "ProductStatus", "default-value": "draft"},
{"name": "hero_image", "type": "Image"},
],
"references": [
{"name": "Category", "cardinality": "one", "back_ref_name": "products"},
],
}An enum schema
python
ProductStatus = {
"name": "ProductStatus",
"namespace": "acmestore",
"schema_type": "enum",
"type": "string",
"values": ["draft", "active", "archived"],
}
ALL_SCHEMAS = [ProductStatus, Product]
PUBLIC_SCHEMAS = ["product"]Field fields
| Field | Meaning |
|---|---|
| name | Field name. |
| type | A primitive type, one of the built-in system types (Image, File, and others), or the name of an enum/type schema you defined. See Schema Reference for the full list. |
| mandatory | Required on write. A record missing a required field is rejected with HTTP 422. |
| values | Allowed values for a string enum field. Enforced on every write; a value outside the list 422s. |
| default-value | Value used when the field is omitted on write. |
Every record the platform stores also carries system fields you never declare: uuid, fq_name, parent_type, parent_uuid, created_at, updated_at. Records key on uuid — never on id.
references[] — relationships, not foreign keys
Model a relationship with references: [{name, cardinality, back_ref_name}] (cardinality is "one" or "many"). Do not invent *_uuid fields to express a relationship. Reads back as a <name>_refs array on the record.
namespace and extends
A schema's true identity is namespace:name, not just name — this is what lets acmestore:product and healthcare:patient coexist as fully separate schemas under the same domain. Put the same namespace literal on every schema dict in your app; it must be a plain string, not a variable or an f-string.
extends lets a schema inherit from a platform base. Use extends: "general:project" (etc.) to add custom fields to a system object without touching the base, or extends: "<service>:base_<name>" — for example "cart:base_cart" — to unlock a stateful managed service such as cart, order, or booking.
config.py — app identity, tenants, and users
config.py defines exactly one dataclass, named AppConfig. The SDK instantiates it once at startup, immediately after your .env is sourced.
python
import os
from dataclasses import dataclass, field
@dataclass
class AppConfig:
app_name: str = "Acme Store"
app_emoji: str = "\U0001F6D2"
app_description: str = "A storefront for designer goods."
domain_name: str = field(default_factory=lambda: os.getenv("SUPERO_DOMAIN", "acme"))
admin_email: str = field(default_factory=lambda: os.getenv("SUPERO_ADMIN_EMAIL", "[email protected]"))
admin_password: str = field(default_factory=lambda: os.getenv("SUPERO_PASSWORD", "") or "Password123!")
project_name: str = field(default_factory=lambda: os.getenv("SUPERO_PROJECT", "acme-store"))
tenants: list = field(default_factory=lambda: [
{"name": "default-tenant", "display_name": "Default Tenant"},
])
users: list = field(default_factory=lambda: [
{"email": "[email protected]", "password": "Password123!", "role": "tenant_admin",
"full_name": "Store Admin", "tenant": "default-tenant"},
{"email": "[email protected]", "password": "Password123!", "role": "tenant_user",
"full_name": "Demo Shopper", "tenant": "default-tenant"},
])
services: list = field(default_factory=lambda: ["email", "workflows"]) # "workflows" must be LAST
public_schemas: list = field(default_factory=lambda: ["product"])| Field | Purpose |
|---|---|
| app_name, app_emoji, app_description | Sidebar and login-page branding. Keep app_emoji a literal emoji character alone on its line — the CLI extracts that line's text verbatim into the generated UI. |
| domain_name, project_name | Read from SUPERO_DOMAIN / SUPERO_PROJECT — never hardcode. |
| tenants | Tenant records to create. The first entry is the one setup and seeding run against, so list default-tenant first while you are developing locally — but read the warning below before this reaches real customers. |
| users | Records of {email, password, role, full_name, tenant} created during setup. role is one of the platform's real roles, most commonly tenant_admin or tenant_user for app-facing accounts. |
| services | Managed services to import (see the Managed Integrations & Provider Catalog). If the list is non-empty, "workflows" must be the last entry. |
| public_schemas | snake_case slugs from schemas.py PUBLIC_SCHEMAS, mirrored into window.__SUPERO_CONFIG.publicSchemas at boot. |
⚠️ default-tenant belongs in local development, not in a real deployment
The scaffold above puts both seeded users in default-tenant, which is the right shape while you are building and testing on your own machine. It must not survive into a deployment that serves real customers: a credential whose tenant is literally named default-tenant is given project-wide access to every tenant in that project regardless of its role, so a tenant_user sitting there can read every other tenant's records. Before you onboard anyone real, create a named tenant per customer and leave default-tenant to your own staff. See Building Multi-Tenant Apps.
⚠️ workflows must be last
If services is non-empty, "workflows" has to be the final entry in the list — import order matters, and an out-of-order list silently drops your workflow and event-binding registration.
setup.py — register, seed, and wire policies
setup.py is the file the CLI actually runs. It builds an AppSetup from your AppConfig plus schemas.py's ALL_SCHEMAS/PUBLIC_SCHEMAS, then calls .run(), which registers the domain, uploads schemas, creates tenants and users, imports services, creates policies, registers workflows and event bindings, and seeds data — in that order.
python
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from supero.app_setup import AppSetup, PolicyDef, PolicyRule, make_seed_record, ref_link
from config import AppConfig
from schemas import ALL_SCHEMAS, PUBLIC_SCHEMAS
# module-level: routes each seed to its own schema's namespace
seed_record = make_seed_record(ALL_SCHEMAS)
POLICIES = [
PolicyDef(role="tenant_admin", default_access="full", rules=[]),
PolicyDef(role="tenant_user", default_access="none", rules=[
PolicyRule(entity="product", can_read=True),
]),
]
def seed_test_data(s, base, domain, tenant_uuid, progress):
categories = {}
for c in [{"name": "outerwear", "display_name": "Outerwear",
"description": "Jackets and coats."}]:
u = seed_record(s, base, domain, "Category", c, progress=progress, tenant_name="default-tenant")
if u:
categories[c["name"]] = u
for p in [{"name": "field-jacket", "display_name": "Field Jacket",
"description": "Waxed cotton field jacket.",
"title": "Field Jacket", "price": 249.0}]:
u = seed_record(s, base, domain, "Product", p, progress=progress, tenant_name="default-tenant")
if u and categories.get("outerwear"):
ref_link(s, base, domain, ALL_SCHEMAS, "Product", u, "Category",
categories["outerwear"], progress=progress)
progress.ok("Seeded categories and products.")
def main():
setup = AppSetup(AppConfig(), ALL_SCHEMAS, PUBLIC_SCHEMAS)
setup.run(seed_fn=seed_test_data, policies=POLICIES)
if __name__ == "__main__":
main()Seeding data
- •make_seed_record(ALL_SCHEMAS) must be built once, at module level.
- •seed_record(...) returns a uuid string on success or None on failure — always guard with "if u:" before using the result.
- •Every seed record needs name, display_name, description, and every field you marked required, or the write is rejected with HTTP 422.
- •seed_record ignores references — link two seeded records with ref_link(s, base, domain, ALL_SCHEMAS, src_type, src_uuid, tgt_type, tgt_uuid, progress=progress), passing the same ALL_SCHEMAS list so both sides resolve their namespace from content.
Policies
One PolicyDef per role: default_access ("full" or "none") sets the fallback, and rules is a list of PolicyRule(entity=..., can_read=..., can_create=..., can_update=...) entries that override the fallback per entity. A PolicyRule can also scope rows to their owner with filter_field and filter_match, for "my records only" access.
The UI: ui_schemas.py or ui/app.js — pick one
Supero apps render their UI one of two ways. A single app uses exactly one — never both.
Declarative — ui_schemas.py
ui_schemas.py exports UI_SCHEMAS, a list of dicts. Each entry extends a base from the platform's UI grammar (schema_type: "ui", extends: "supero_ui:base_...") and covers one of: a per-entity admin UI (view, card renderer, featured fields, field overrides), a dashboard widget, a landing page, or the app shell (tabs, theme, dashboard charts). This is pure data — the SDK renders all of it. ui/app.js then becomes a thin shim that resolves the app-shell entry and forwards it into AppShell.render({...}); it does not hardcode tabs or cards.
python
UI_SCHEMAS = [
{
"name": "product_admin_ui",
"namespace": "acmestore",
"schema_type": "ui",
"schema_content": {
"name": "product_admin_ui",
"parent_type": "tenant",
"extends": "supero_ui:base_record_ui",
"description": "Admin panel UI for products.",
"attributes": [
{"name": "ui_for", "type": "string", "default-value": "acmestore:product"},
{"name": "surface", "type": "string", "default-value": "admin_panel"},
{"name": "default_view_mode", "type": "string", "default-value": "list"},
],
},
},
]Hand-built — ui/app.js
The alternative is to write ui/app.js yourself, with no ui_schemas.py. Two sub-patterns, both calling the same locked client.* / services.* primitives underneath:
- •Call AppShell.render({tabs, appName, appEmoji, theme, ...}) directly with your own tab definitions — fast, zero custom React, the same renderer the declarative path uses.
- •Mount your own React tree and render whatever layout you want — full control, more code to own.
🚨 Do not mix the two
Never resolve a ui_schemas.py app-shell entry into AppShell.render() and also mount a separate hand-written React root in the same app.js. Two React roots fighting over the DOM fails silently — you get a blank screen with no console error.
Runtime-generated files — do not hand-edit
On first run, the CLI materializes a UI runtime around the files you wrote. Editing any of the files below directly is a wasted edit: most are regenerated on every run, so your changes are silently discarded the next time run.sh (or the CLI) executes.
| File | Regenerated | What it does |
|---|---|---|
| ui/index.html | every run | Loads config.js, then the runtime scripts, then app.js. |
| ui/config.js | every run | Writes window.__SUPERO_CONFIG from your .env, config.py, and schemas.py PUBLIC_SCHEMAS. |
| ui/server.py | every run | Dev server plus an /api and /ai proxy to the platform. |
| ui/*.js runtime | every run | The layered SDK UI library — data client, services, field renderer, dispatcher, AppShell, and the rest of the standard components. |
| Dockerfile, docker-compose.yml | only if missing | Written with a working default the first time. Once you commit a customized version, the CLI leaves it alone. |
The load order matters when you are debugging a blank screen: index.html loads config.js, then the schema registry, then the dispatcher and field renderer, then the rest of the layered runtime, and only then your app.js. By the time app.js executes, client, services, and the React globals already exist on the page — you never import, initialize, or re-declare them yourself.
window.__SUPERO_CONFIG field reference
config.js writes one global object, window.__SUPERO_CONFIG, from your .env, config.py, and schemas.py at boot. Read from it in app.js instead of hardcoding any of these values — IDs are snake_case, display/UI fields are camelCase.
| Field | Derived from | Notes |
|---|---|---|
| domain | SUPERO_DOMAIN | The app's domain slug. |
| project | SUPERO_PROJECT | Project slug, not the UUID. |
| project_uuid | SUPERO_PROJECT_UUID | Required by service calls (client.transactional.*, services.*) that expect a UUID, not a slug. |
| tenant | — | Usually "default-tenant". Not a per-logged-in-user value — pass an empty tenant on login and let the server resolve the user's actual tenant. |
| apiUrl | SUPERO_URL | Defaults to https://api.supero.dev. |
| appName, appEmoji, appDescription | config.py AppConfig | Branding — do not hardcode these in your UI. |
| publicSchemas | schemas.py PUBLIC_SCHEMAS | Injected at runtime. Never set this yourself in AppShell.render() or app.js — a hardcoded value diverges from schemas.py and the runtime cannot reconcile the two. |
| isMultiTenant | derived from config.py tenants | True once more than one tenant is registered. |
| tenantNoun | — | {singular, plural} — for label pluralization in multi-tenant apps. |
| appNamespace | derived from schemas.py namespace | The namespace your schemas were uploaded under. Required for the client to resolve its own schemas. |
⚠️ appNamespace drives what the storefront can see
appNamespace and publicSchemas are both derived from schemas.py, not typed by hand. If appNamespace resolves to the wrong value — for example the domain instead of the app's own namespace — the app still loads, but every shelf is empty and the landing page falls back to a generic default instead of your data. Check the namespace literal in schemas.py and confirm the project was seeded before looking anywhere else.
Next steps
- •Schema Reference — a deeper look at field types, validation, and relationships.
- •GitHub Resources & Sample Apps — fifteen complete apps you can read to see this anatomy in a real codebase.
- •Dynamic UI — how the SDK renders forms, tables, and detail views from schema.
- •Access Control — designing PolicyDef/PolicyRule sets and role scoping in depth.
- •Cloud Deployment — taking a finished bundle from run.sh to a hosted app.
On this page