Skip to content

3. Write your first capability

A capability is the unit of pluggability. To add a JMAP data type, you write three things:

  1. A SQLAlchemy model that inherits the standard jmaple columns.
  2. A Pydantic schema for the wire shape.
  3. A CrudCapability subclass that wires those together and registers Todo/get, Todo/set, Todo/query, Todo/changes, Todo/copy, Todo/queryChanges automatically.

Plus one line of entry-point registration in your pyproject.toml.

Scaffold

Create a new uv project:

mkdir todos && cd todos
uv init --python 3.14
uv add jmaple
mkdir -p src/example_todos
touch src/example_todos/__init__.py

1. The model

The object table — one row per Todo:

src/example_todos/models.py
from jmaple.db import Base, JMAPObject
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column


class Todo(Base, JMAPObject):
    __tablename__ = "todos"
    text: Mapped[str] = mapped_column(String(512), nullable=False)
    done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

JMAPObject adds the standard columns: id, account_id, created, updated. You don't write a change-log table — the framework owns one shared data_changes table that backs <Type>/changes for every capability.

2. The wire schema

Pydantic models for what clients send and receive:

src/example_todos/schema.py
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from jmaple.types import Id


_WIRE = ConfigDict(populate_by_name=True, alias_generator=to_camel)


class TodoSchema(BaseModel):
    model_config = ConfigDict(
        populate_by_name=True, alias_generator=to_camel, from_attributes=True,
    )
    id: Id
    text: str
    done: bool = False
    created: datetime
    updated: datetime


class TodoCreate(BaseModel):
    model_config = ConfigDict(
        populate_by_name=True, alias_generator=to_camel, extra="forbid",
    )
    text: str
    done: bool = False


class TodoFilter(BaseModel):
    model_config = ConfigDict(
        populate_by_name=True, alias_generator=to_camel, extra="forbid",
    )
    text: str | None = None
    done: bool | None = None

to_camel round-trips account_idaccountId automatically. populate_by_name=True accepts either form on input.

3. The capability

src/example_todos/capability.py
from typing import Any
from jmaple.capabilities import CrudCapability
from example_todos import models, schema


class TodosCapability(CrudCapability):
    urn = "urn:example:todos"
    data_type_name = "Todo"
    model = models.Todo
    schema_class = schema.TodoSchema
    create_schema_class = schema.TodoCreate
    filter_schema_class = schema.TodoFilter

    @classmethod
    def filter_clauses(cls, filter_: Any, account_id: str) -> list[Any]:
        clauses = [models.Todo.account_id == account_id]
        if filter_ is None:
            return clauses
        if filter_.text:
            clauses.append(models.Todo.text.ilike(f"%{filter_.text}%"))
        if filter_.done is not None:
            clauses.append(models.Todo.done == filter_.done)
        return clauses


capability = TodosCapability.as_capability()

Three required class attributes (model, schema_class, create_schema_class), plus the URN, data-type name, and an optional filter schema. The framework infers everything else.

4. Register the capability

Registration is programmatic — there are no entry points and no JMAPLE_ENABLED_CAPABILITIES env var. Plugin authors call register_capability() from their own code at import time.

Write two thin modules — an ASGI app for serving, and a CLI wrapper for management ops:

src/example_todos/app.py
from jmaple.app import create_app
from jmaple.capabilities import register_capability

# Importing the models module places Todo on Base.metadata so Alembic
# autogenerate sees it.
from example_todos import models as _models  # noqa: F401
from example_todos.capability import capability

register_capability(capability)

app = create_app()
src/example_todos/cli.py
from jmaple.capabilities import register_capability
from jmaple.cli import app

from example_todos import models as _models  # noqa: F401
from example_todos.capability import capability

register_capability(capability)


if __name__ == "__main__":
    app()

Then repoint the jmaple console script at your CLI wrapper in pyproject.toml:

[project.scripts]
jmaple = "example_todos.cli:app"

uv run jmaple … now imports your CLI wrapper first, which registers the capability before jmaple's Typer app dispatches.

5. Migrate

uv run jmaple migrate revision -m "todos plugin"
uv run jmaple migrate upgrade head

Alembic's autogenerate picks up the Todo model because the CLI wrapper imports example_todos.models, placing it on Base.metadata before Alembic inspects it.

6. Try it

uv run uvicorn example_todos.app:app --reload &
TOKEN='chl_…'
ACCOUNT_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \
  http://localhost:8000/.well-known/jmap | jq -r '.accounts | keys[0]')

curl -s -X POST http://localhost:8000/jmap \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{
    \"using\": [\"urn:ietf:params:jmap:core\", \"urn:example:todos\"],
    \"methodCalls\": [
      [\"Todo/set\", {
        \"accountId\": \"$ACCOUNT_ID\",
        \"create\": {\"t1\": {\"text\": \"write docs\"}}
      }, \"c0\"],
      [\"Todo/query\", {\"accountId\": \"$ACCOUNT_ID\"}, \"c1\"]
    ]
  }" | jq

You wrote ~50 lines of Python and got six standard JMAP methods working.

Next: understanding state and changes →