3. Write your first capability¶
A capability is the unit of pluggability. To add a JMAP data type, you write three things:
- A SQLAlchemy model that inherits the standard jmaple columns.
- A Pydantic schema for the wire shape.
- A
CrudCapabilitysubclass that wires those together and registersTodo/get,Todo/set,Todo/query,Todo/changes,Todo/copy,Todo/queryChangesautomatically.
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:
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:
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_id ↔ accountId automatically.
populate_by_name=True accepts either form on input.
3. The capability¶
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:
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()
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:
uv run jmaple … now imports your CLI wrapper first, which registers the
capability before jmaple's Typer app dispatches.
5. Migrate¶
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.