Skip to content
Menu

Documentation

Customize a standard module

Goal: make a standard module fit your product while you keep receiving its upgrades.

Standard modules are installed packages. Your code talks to them the way any module talks to another: through contracts and events. Take the first option on this page that does the job.

You want to Use
Change limits, keys, sign-in providers or other behaviour with a setting Configuration
Store more data about a user or an organization A module next to it
React when a user registers or is deleted A consumer
Add values to the access token A claims contributor
Deliver its email or use your own text A notification driver

1. Configuration

The settings of each module are in its page (identity, tenancy, billing) and in the configuration reference.

2. A module next to it

Add fields by adding a module, not by editing identity. This profiles module stores a display name for the current user, keyed by the user_id from the token, and removes it when identity deletes the user.

uv run sandloom add module profiles
uv run sandloom add consumer profiles identity.user_deleted.v1

Delete the generated models/item.py and add this model. A profile belongs to a user, not to a tenant, so it inherits Base alone. Edit the generated baseline migration to create this table, without the row-level-security calls (Add a module):

profiles/models/profile.py
"""One profile per user, keyed by the id identity puts in the token."""

import uuid

from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column

from sandloom.sdk import Base, CreatedAt


class Profile(Base):
    """``Base`` alone: a profile belongs to a user, not to a tenant."""

    __tablename__ = "profiles_profiles"

    user_id: Mapped[uuid.UUID] = mapped_column(primary_key=True)
    display_name: Mapped[str] = mapped_column(String(100))
    created_at: Mapped[CreatedAt]

Replace the generated schemas.py and services.py, delete the generated events.py (this module publishes nothing), and give it routes for the current user:

profiles/schemas.py
"""Request and response shapes of the profiles module."""

from pydantic import BaseModel, Field


class ProfileIn(BaseModel):
    display_name: str = Field(min_length=1, max_length=100)


class ProfileOut(BaseModel):
    display_name: str
profiles/services.py
"""Use cases of the profiles module."""

from uuid import UUID

from sandloom.sdk import db

from .models.profile import Profile
from .schemas import ProfileIn, ProfileOut


async def get_profile(user_id: UUID) -> ProfileOut | None:
    profile = await db().get(Profile, user_id)
    return ProfileOut(display_name=profile.display_name) if profile else None


async def save_profile(user_id: UUID, body: ProfileIn) -> ProfileOut:
    profile = await db().get(Profile, user_id)
    if profile is None:
        db().add(Profile(user_id=user_id, display_name=body.display_name))
    else:
        profile.display_name = body.display_name
    await db().flush()
    return ProfileOut(display_name=body.display_name)


async def delete_profile(user_id: UUID) -> None:
    profile = await db().get(Profile, user_id)
    if profile is not None:
        await db().delete(profile)
        await db().flush()
profiles/api.py
"""Routes of the profiles module: ``/api/v1/profiles/...``."""

from fastapi import HTTPException

from sandloom.sdk import CurrentUser, module_router

from . import services
from .schemas import ProfileIn, ProfileOut

router = module_router()


@router.get("/me")
async def read_my_profile(user: CurrentUser) -> ProfileOut:
    profile = await services.get_profile(user.user_id)
    if profile is None:
        raise HTTPException(status_code=404, detail="No profile yet")
    return profile


@router.put("/me")
async def save_my_profile(body: ProfileIn, user: CurrentUser) -> ProfileOut:
    return await services.save_profile(user.user_id, body)

Replace the generated consumer. The event carries the id in its payload, with no personal data, and the view declares only the field this module reads:

profiles/consumers.py
"""Consumers of the profiles module."""

from typing import Any
from uuid import UUID

from sandloom.sdk import Event, on

from . import services


class UserDeleted(Event, channel="identity.user_deleted.v1"):
    """A view: the fields of identity's event this module reads."""

    payload: dict[str, Any]


# dead_letter=False: erasure keeps retrying rather than waiting in a dead-letter queue.
@on(UserDeleted, dead_letter=False)
async def on_user_deleted(event: UserDeleted) -> None:
    await services.delete_profile(UUID(event.payload["user_id"]))

Replace the generated test. Pass session=db to deliver so the consumer runs in the test's transaction and sees the row the route wrote:

profiles/tests/test_profiles.py
"""Tests of the profiles module."""

from sandloom.sdk.testing import as_user, deliver


async def test_a_profile_is_saved_and_removed_with_its_user(client, db) -> None:
    user_id, headers = as_user()

    saved = await client.put(
        "/api/v1/profiles/me", json={"display_name": "Ada"}, headers=headers
    )
    assert saved.status_code == 200
    read = await client.get("/api/v1/profiles/me", headers=headers)
    assert read.json() == {"display_name": "Ada"}

    await deliver(
        "identity.user_deleted.v1",
        {
            "event_id": "019a0000-0000-7000-8000-000000000001",
            "event_type": "identity.user_deleted.v1",
            "occurred_at": "2026-10-31T00:00:00Z",
            "schema_version": 1,
            "actor": {"user_id": str(user_id)},
            "trace_id": "test",
            "payload": {"user_id": str(user_id)},
        },
        session=db,
    )
    gone = await client.get("/api/v1/profiles/me", headers=headers)
    assert gone.status_code == 404

Run uv run sandloom generate, then uv run sandloom test profiles.

Identity's contact details (identity.contact.v1) are the source for email and name at send time: call the contract instead of copying them (how other modules use identity).

Add values to the access token

A module can add claims to the tokens identity mints, with add_claims_contributor in its setup (ModuleAPI). Keep them small and non-secret: a token outlives the data it was built from.

3. A notification driver

Identity sends all mail through the notification capability, so a driver decides how it is delivered. Each message carries its type in notification.data["kind"]: email_verification, password_reset, registration_attempt or security_notice.

A driver renders its own template from kind and notification.body_fields, the values identity built the plain-text body from:

kind body_fields
email_verification verification_url, full_name when known
password_reset reset_url, full_name when known
registration_attempt sign_in_url, reset_url, full_name when known
security_notice message

These values are as sensitive as the body: the links are secrets, so a driver never logs them. A later identity version can add a kind, and full_name can be missing, so fall back to notification.body whenever a template cannot be filled:

TEMPLATES = {"password_reset": "Hello! Choose a new password: {reset_url}"}


def render(notification: Notification) -> str:
    template = TEMPLATES.get(notification.data.get("kind", ""))
    if template is None:
        return notification.body
    try:
        return template.format(**notification.body_fields)
    except KeyError:
        return notification.body

What is not supported yet

  • Changing a standard module's own tables or routes. There are no extension points for these yet.
  • Importing a standard module's code, or patching it at runtime. An import fails SANDLOOM-C001. Internals may change in any release.
  • A local copy with the same name next to the package. The loader refuses it (SANDLOOM-B020).
  • Ejecting a module into your application with a command. It does not exist yet.

If you need one of these, open an issue with your use case.