# Customize a standard module

**Goal:** make a [standard module](<https://sandloom.dev/docs/modules/standard/index.md>) 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](<#1-configuration>) |
| Store more data about a user or an organization | [A module next to it](<#2-a-module-next-to-it>) |
| React when a user registers or is deleted | [A consumer](<#2-a-module-next-to-it>) |
| Add values to the access token | [A claims contributor](<#add-values-to-the-access-token>) |
| Deliver its email or use your own text | [A notification driver](<#3-a-notification-driver>) |

## 1\. Configuration

The settings of each module are in its page ([identity](<https://sandloom.dev/docs/modules/standard/identity/#capabilities-configuration>), [tenancy](<https://sandloom.dev/docs/modules/standard/tenancy/#capabilities-configuration>), [billing](<https://sandloom.dev/docs/modules/standard/billing/#capabilities-configuration>)) and in the [configuration reference](<https://sandloom.dev/docs/reference/configuration/index.md>).

## 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](<https://sandloom.dev/docs/modules/create/#3-replace-the-example-with-your-domain>)):

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](<https://sandloom.dev/docs/modules/standard/identity/#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`](<https://sandloom.dev/docs/reference/sdk/module/index.md>)). 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](<https://sandloom.dev/docs/infrastructure/write-a-driver/index.md>) 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](<https://sandloom.dev/docs/reference/error-codes/#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](<https://sandloom.dev/docs/reference/error-codes/#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](<https://sandloom.dev/docs/getting-started/help/index.md>) with your use case.

## Related

- [Standard modules](<https://sandloom.dev/docs/modules/standard/index.md>)
- [Events](<https://sandloom.dev/docs/modules/between/events/index.md>) and [contracts](<https://sandloom.dev/docs/modules/between/contracts/index.md>)
- [Write a driver](<https://sandloom.dev/docs/infrastructure/write-a-driver/index.md>)
