Skip to content

Capabilities

A capability is the unit of pluggability in jmaple. It's a single Python object — an instance of Capability — that declares:

  • A URN (urn:vendor:thing).
  • A function returning the properties advertised on the JMAP session resource for this capability.
  • A function returning per-account properties (visible in accountCapabilities).
  • Zero or more data types (each with a SQLAlchemy model).
  • Zero or more method handlers (<Type>/get, <Type>/set, …).
  • An optional Pydantic settings model.

You almost never construct a Capability directly. Use one of the declarative helpers:

CrudCapability — the common case

CrudCapability subclasses get six standard methods (get, changes, set, query, copy, queryChanges) generated from a handful of class attributes.

Required:

Attribute Purpose
urn The capability URN, advertised on /.well-known/jmap
data_type_name The wire name of your data type, e.g. "Todo"
model Your SQLAlchemy model class
schema_class Pydantic wire schema for the object
create_schema_class Pydantic schema for create payloads

Optional:

Attribute Default Purpose
filter_schema_class None Type-validates the filter arg of <Type>/query
enabled_methods all six frozenset of method names to register
allowed_update_properties create-schema fields Which fields <Type>/set update accepts
sortable_properties model column names Which properties <Type>/query sort accepts

Hook methods to override:

  • filter_clauses(filter_, account_id) — convert the JMAP filter object to SQLAlchemy WHERE clauses.
  • to_schema(instance) — convert a model row to its Pydantic wire shape. Default uses model_validate(instance, from_attributes=True).
  • create_model(payload, account_id, obj_id) — build a model instance from a create payload. Default unpacks the schema fields.
  • clone_model(source, payload, account_id, obj_id) — build a copied row for <Type>/copy. Default copies every non-identity column.
  • get_session_properties(settings) / get_account_properties(account) — control what shows up under your URN on the session resource.
  • get_custom_methods() — return additional non-CRUD method handlers under this capability's URN.

BaseCapability — for non-CRUD shapes

For session-only capabilities (urn:ietf:params:jmap:websocket is an example), or when you want full manual control over method handlers, subclass BaseCapability and override get_methods() to return a list of MethodHandler instances directly.

How jmaple finds your capability

Registration is programmatic: plugin authors call register_capability(my_cap) from their own code at app boot. There is no entry-point discovery, no JMAPLE_ENABLED_CAPABILITIES env var, and no runtime gating by URN. The import graph is the source of truth.

The framework's bundled capabilities (core, websocket, blob, notes) are registered automatically by jmaple.capabilities.registry.discover() on first use. Your code adds to that set:

# my_app/app.py — your ASGI entry point
from jmaple.app import create_app
from jmaple.capabilities import register_capability
from my_app.capability import capability

register_capability(capability)
app = create_app()

Then run uvicorn my_app.app:app.

For management commands, write a CLI wrapper that does the same registration and re-exposes jmaple's Typer app:

# my_app/cli.py
from jmaple.capabilities import register_capability
from jmaple.cli import app
from my_app.capability import capability

register_capability(capability)


if __name__ == "__main__":
    app()

Point pyproject.toml's jmaple script at it:

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

uv run jmaple … now imports your wrapper first, so registration runs before any command dispatches.

Capability.as_capability() materialises a Capability instance from a declarative subclass — call it once at module scope:

capability = TodosCapability.as_capability()

How the dispatcher knows which method to run

When a JMAP request arrives:

  1. The dispatcher validates that every URN in using is registered.
  2. For each method call in methodCalls, it looks up the method name in the registry (Foo/bar maps to one MethodHandler).
  3. If the method belongs to a capability that isn't in the request's using, the call fails with unknownMethod (RFC 8620 §3.3).
  4. The handler is invoked with the validated args and a per-invocation MethodContext.

The dispatcher commits the SQLAlchemy session once at the end of the request, or rolls back if any handler raises an unhandled exception.