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
HTTPStatusconstants - 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
extendsline 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-panelis 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
- App Manifest — full manifest field reference
- Building Forms — add a form drawer for creating bookmarks
- Event System — emit events when bookmarks are created
- Publishing Your App — submit to the marketplace