Getting started

Your First App

This walkthrough builds a complete Saaslivery app from scratch — a simple bookmarks manager. You'll create the manifest, plugin, handlers, templates, and API routes.


1. Scaffold the directory

apps/bookmarks/
├── __init__.py
├── manifest.py
├── plugin.py
├── helpers.py           # Constants, DB row converters, shared logic
├── apis.py              # API handlers (JSON responses)
├── pages.py             # UI pages + HTMX partials (HTML responses)
├── schema.py
├── migrations/
│   └── 001_create_bookmarks_tables.sql
├── templates/           # Flat; the TEMPLATES prefix namespaces them
│   ├── index.html
│   └── partials/
│       └── list.html
└── assets/
    └── style.css

Create __init__.py as an empty file.


2. Define the manifest

manifest = {
    "name": "Bookmarks",
    "slug": "bookmarks",
    "publisher": "microcessor",
    "version": "0.1.0",
    "description": "Save and organize bookmarks across your workspace",
    "requires": [],
    "enhances": [],
    "events": {
        "emits": [
            "core.bookmarks.bookmark.created",
            "core.bookmarks.bookmark.deleted",
        ],
        "listens": [],
    },
    "pwa": {
        "theme_color": "#3B82F6",
        "background_color": "#ffffff",
    },
    "category": "productivity",
    "tags": ["bookmarks", "links", "resources"],
    "pricing": "free",
}

3. Create the plugin

from heaven import App

app = App(debug=False)
app.TEMPLATES("templates", relative_to=__file__, prefix="bookmarks")

# UI routes: {workspace}.saaslivery.com/app/bookmarks/*
ws = app.subdomain("*")
ws.GET("/app/bookmarks/", "apps.bookmarks.pages.index")
ws.GET("/app/bookmarks/partials/list", "apps.bookmarks.pages.bookmark_list")

# API routes: api.saaslivery.com/bookmarks/*
api = app.subdomain("api")
api.GET("/bookmarks", "apps.bookmarks.apis.api_list")
api.POST("/bookmarks", "apps.bookmarks.apis.api_create")
api.DELETE("/bookmarks/:id", "apps.bookmarks.apis.api_delete")

Important

  • All UI routes go on the wildcard subdomain (app.subdomain("*")) under your /app/{slug}/ prefix. Never register a named subdomain for UI; see Architecture & routing.
  • Always use App(debug=False); the platform owns debug tooling.
  • Do NOT register ASSETS(), CORS, or sessions; the platform handles these.

4. Define the schema

from typing import Annotated, TypedDict
from typing import NotRequired


class CreateBookmarkSchema(TypedDict):
    url: Annotated[str, "min_len=1; max_len=2000"]
    title: Annotated[str, "min_len=1; max_len=200"]
    description: NotRequired[Annotated[str, "max_len=500"]]

5. Write the database schema

Create the migration: ./migrate.sh create bookmarks create_bookmarks_tables

CREATE TABLE IF NOT EXISTS bookmarks (
    id           TEXT PRIMARY KEY,
    workspace_id TEXT NOT NULL,
    url          TEXT NOT NULL,
    title        TEXT NOT NULL,
    description  TEXT DEFAULT '',
    created_by   TEXT NOT NULL,
    created_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_bookmarks_workspace ON bookmarks(workspace_id);

One table. Well under the 10-table limit. Note the IF NOT EXISTS guards: every migration must be idempotent, safe to run twice.


6. Write the handlers

from heaven import Request, Response, Context


async def index(req: Request, res: Response, ctx: Context):
    """Main page, rendered into a shell tab or served standalone."""
    await res.render("bookmarks/index.html")


async def bookmark_list(req: Request, res: Response, ctx: Context):
    """HTMX partial, returns just the bookmark list."""
    bookmarks = []  # TODO: fetch from ctx.db
    await res.render("bookmarks/partials/list.html", bookmarks=bookmarks)
from http import HTTPStatus

from heaven import Request, Response, Context
from pytastic import ValidationError, validate

from apps.bookmarks.schema import CreateBookmarkSchema
from core.responses import many, one, parse_pagination


async def api_list(req: Request, res: Response, ctx: Context):
    """API: list all bookmarks for this workspace."""
    page, per_page = parse_pagination(req)
    bookmarks: list = []  # TODO: fetch from ctx.db
    return res.out(HTTPStatus.OK, many(
        bookmarks, total=len(bookmarks), page=page, per_page=per_page, path="/bookmarks",
    ))


async def api_create(req: Request, res: Response, ctx: Context):
    """API: create a new bookmark."""
    data = req.json or {}
    try:
        validated = validate(CreateBookmarkSchema, data, strip=True)
    except ValidationError as e:
        return res.out(
            HTTPStatus.UNPROCESSABLE_ENTITY,
            {"error": e.errors[0]["message"] if e.errors else str(e)},
        )

    # TODO: persist to ctx.db
    return res.out(HTTPStatus.CREATED, one({"id": "new", **validated}, links={"self": "/bookmarks/new"}))


async def api_delete(req: Request, res: Response, ctx: Context):
    """API: delete a bookmark."""
    bookmark_id = req.params["id"]
    # TODO: delete from ctx.db
    return res.out(HTTPStatus.OK, {"ok": True})

Notice:

  • Every handler is type-annotated with Request, Response, Context
  • Status codes use HTTPStatus constants
  • Responses use the concise res.out() pattern

7. Create the templates

{% extends ctx._base_template|default("platform/base.html") %}

{% block title %}Bookmarks | Saaslivery{% endblock %}

{% block head %}
<link rel="stylesheet" href="/assets/style.css">
{% endblock %}

{% block body %}
<div class="container py-4">
  <div class="d-flex align-items-center justify-content-between mb-4">
    <h1>Bookmarks</h1>
    <button type="button" class="btn btn-primary btn-sm"
            hx-get="/app/bookmarks/partials/forms/new-bookmark"
            hx-target="#form-panel"
            hx-swap="innerHTML">
      Add Bookmark
    </button>
  </div>

  <div hx-get="/app/bookmarks/partials/list"
       hx-trigger="load"
       hx-swap="innerHTML">
    Loading...
  </div>
</div>
{% endblock %}

Two things to notice:

  • The extends line is how one template serves both worlds: a direct browser hit gets the full page chrome, and a shell tab load gets a bare fragment. The platform sets the base for you.
  • #form-panel is not declared here. The platform's shell and base template provide exactly one #form-panel; apps target it and never declare their own.
{% if bookmarks %}
<div class="d-flex flex-column gap-2">
  {% for b in bookmarks %}
  <div class="card p-3">
    <a href="{{ b.url }}" target="_blank" class="fw-semibold">{{ b.title }}</a>
    {% if b.description %}
    <p class="text-muted small mb-0 mt-1">{{ b.description }}</p>
    {% endif %}
  </div>
  {% endfor %}
</div>
{% else %}
<p class="text-muted">No bookmarks yet. Add one to get started.</p>
{% endif %}

8. Test locally

from heaven import App
from apps.bookmarks.plugin import app as bookmarks_app

dev = App()
dev.TEMPLATES("templates", relative_to=__file__)
dev.ASSETS("apps/bookmarks/assets", route="/assets/*", relative_to=__file__)
dev.mount(bookmarks_app, isolated=False)
dev.listen(host="localhost", port=8701)
python dev.py
# Visit http://localhost:8701

9. Test with Earth

import unittest
from heaven import App
from apps.bookmarks.plugin import app as bookmarks_app


class TestBookmarks(unittest.IsolatedAsyncioTestCase):
    async def asyncSetUp(self):
        self.app = App(debug=False)
        self.app.mount(bookmarks_app, isolated=False)

    async def test_home_page(self):
        async with self.app.earth.test() as client:
            req, res, ctx = await client.GET("/app/bookmarks/", subdomain="acme")
            self.assertEqual(res.status, 200)

    async def test_api_create_validates(self):
        async with self.app.earth.test() as client:
            req, res, ctx = await client.POST(
                "/bookmarks",
                subdomain="api",
                body={"url": "", "title": ""},
            )
            self.assertEqual(res.status, 422)

UI routes live on the wildcard subdomain, so any subdomain (here acme) reaches them in the standalone harness; API routes are targeted with subdomain="api".


Next steps