Getting started

Project structure

Every Saaslivery app, core or third-party, uses the same layout and the same split of responsibilities. The structure is a contract: automated checks at submission time and code review both assume it.

Prerequisites

  • Python 3.11+
  • The Heaven framework (pip install heaven)
  • Basic familiarity with async Python and Jinja2
  • HTMX and Alpine.js knowledge helps; both are served by the platform, nothing to install

The layout

my-app/
├── __init__.py          # Empty, makes it a Python package
├── manifest.py          # Identity, dependencies, events, permissions, pricing
├── plugin.py            # Heaven App() + route registration
├── schema.py            # pytastic TypedDict schemas (input + output)
├── helpers.py           # Constants, row converters, shared query logic
├── apis.py              # API handlers (JSON responses)
├── pages.py             # UI pages + HTMX partials (HTML responses)
├── authz.py             # AUTHZ dict + authorization functions
├── events.py            # Event bus handlers (optional)
├── forms.py             # Form definitions (optional)
├── events.yml           # Emitted actions + subscriptions (if the app uses events)
├── migrations/          # Database schema, max 10 tables
│   └── 001_create_my_app_tables.sql
├── templates/           # Flat; the TEMPLATES prefix namespaces them
│   ├── index.html
│   └── partials/
└── assets/
    └── style.css        # App-specific CSS, served at /assets/*

One file, one job

File Returns Never contains
pages.py HTML via res.render() Business logic for mutations
apis.py JSON via res.out() HTML strings
helpers.py Shared logic for both Route registrations
authz.py Authorization decisions Anything else

There is never a single handlers.py. If a function renders HTML it belongs in pages.py; if it returns JSON it belongs in apis.py; if both need it, it belongs in helpers.py.

Key rules

Rule Why
Max 10 database tables Keeps apps small; decompose over the event bus if you need more
workspace_id on every table The platform enforces workspace isolation
App(debug=False) The platform owns debug and livereload
No ASSETS(), CORS, or sessions in plugin.py The platform registers these globally
Templates flat, namespaced by prefix app.TEMPLATES("templates", relative_to=__file__, prefix="my-app")
UI on the wildcard subdomain under /app/{slug}/ See Architecture & routing
API routes namespaced /{slug}/* on api Prevents collisions on the shared API subdomain
Type-annotated handlers (req: Request, res: Response, ctx: Context), always
HTTPStatus constants Never magic numbers
Imports at module level Never inside functions
Events for all cross-app communication Never import from or query another app

How apps are discovered

The platform scans apps/ (core) and plugins/ (third-party) at startup for directories containing plugin.py. For each one it:

  1. Imports the module and mounts the exported app object
  2. Reads manifest.py for the slug, events, and permission catalogue
  3. Registers /public/* (platform assets) and /assets/* (your assets)
  4. Registers the app's events.yml actions and subscriptions on the event bus
  5. Merges the app's AUTHZ dict into the platform authorization gate

Your app does nothing special to be discovered. Follow the structure, export app from plugin.py, and the platform does the rest.

Next steps