diff --git a/docs/python/sdk-reference.md b/docs/python/sdk-reference.md
index dee669b3..f1bf5257 100644
--- a/docs/python/sdk-reference.md
+++ b/docs/python/sdk-reference.md
@@ -10,6 +10,10 @@ title: Python SDK API Reference
     rendering:
       show_root_full_path: false
 
+## ::: planet.MosaicsClient
+    rendering:
+      show_root_full_path: false
+
 ## ::: planet.OrdersClient
     rendering:
       show_root_full_path: false
diff --git a/examples/mosaics-cli.sh b/examples/mosaics-cli.sh
new file mode 100755
index 00000000..997c93c9
--- /dev/null
+++ b/examples/mosaics-cli.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+echo -e "List the mosaic series that have the word Global in their name"
+planet mosaics series list --name-contains=Global | jq .[].name
+
+echo -e "\nWhat is the latest mosaic in the series named Global Monthly, with output indented"
+planet mosaics series list-mosaics "Global Monthly" --latest --pretty
+
+echo -e "\nHow many quads are in the mosaic with this ID (name also accepted!)?"
+planet mosaics search 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 | jq .[].id
+
+echo -e "\nWhat scenes contributed to this quad in the mosaic with this ID (name also accepted)?"
+planet mosaics contributions 09462e5a-2af0-4de3-a710-e9010d8d4e58 455-1273
+
+echo -e "\nDownload them to a directory named quads!"
+planet mosaics download 09462e5a-2af0-4de3-a710-e9010d8d4e58 --bbox=-100,40,-100,40.1 --output-dir=quads
\ No newline at end of file
diff --git a/planet/__init__.py b/planet/__init__.py
index fe2729fe..a7639b00 100644
--- a/planet/__init__.py
+++ b/planet/__init__.py
@@ -16,7 +16,7 @@
 from . import data_filter, order_request, reporting, subscription_request
 from .__version__ import __version__  # NOQA
 from .auth import Auth
-from .clients import DataClient, FeaturesClient, OrdersClient, SubscriptionsClient  # NOQA
+from .clients import DataClient, FeaturesClient, MosaicsClient, OrdersClient, SubscriptionsClient  # NOQA
 from .io import collect
 from .sync import Planet
 
@@ -26,6 +26,7 @@
     'DataClient',
     'data_filter',
     'FeaturesClient',
+    'MosaicsClient',
     'OrdersClient',
     'order_request',
     'Planet',
diff --git a/planet/cli/cli.py b/planet/cli/cli.py
index 328cf4f9..98571ec1 100644
--- a/planet/cli/cli.py
+++ b/planet/cli/cli.py
@@ -19,6 +19,7 @@
 import click
 
 import planet
+from planet.cli import mosaics
 
 from . import auth, collect, data, orders, subscriptions, features
 
@@ -79,3 +80,4 @@ def _configure_logging(verbosity):
 main.add_command(subscriptions.subscriptions)  # type: ignore
 main.add_command(collect.collect)  # type: ignore
 main.add_command(features.features)
+main.add_command(mosaics.mosaics)
diff --git a/planet/cli/mosaics.py b/planet/cli/mosaics.py
new file mode 100644
index 00000000..8254da44
--- /dev/null
+++ b/planet/cli/mosaics.py
@@ -0,0 +1,272 @@
+import asyncio
+from contextlib import asynccontextmanager
+
+import click
+
+from planet.cli.cmds import command
+from planet.cli.io import echo_json
+from planet.cli.session import CliSession
+from planet.cli.types import BoundingBox, DateTime, Geometry
+from planet.cli.validators import check_geom
+from planet.clients.mosaics import MosaicsClient
+
+
+@asynccontextmanager
+async def client(ctx):
+    async with CliSession() as sess:
+        cl = MosaicsClient(sess, base_url=ctx.obj['BASE_URL'])
+        yield cl
+
+
+include_links = click.option("--links",
+                             is_flag=True,
+                             help=("If enabled, include API links"))
+
+name_contains = click.option(
+    "--name-contains",
+    type=str,
+    help=("Match if the name contains text, case-insensitive"))
+
+bbox = click.option('--bbox',
+                    type=BoundingBox(),
+                    help=("Region to download as comma-delimited strings: "
+                          " lon_min,lat_min,lon_max,lat_max"))
+
+interval = click.option("--interval",
+                        type=str,
+                        help=("Match this interval, e.g. 1 mon"))
+
+acquired_gt = click.option("--acquired_gt",
+                           type=DateTime(),
+                           help=("Imagery acquisition after than this date"))
+
+acquired_lt = click.option("--acquired_lt",
+                           type=DateTime(),
+                           help=("Imagery acquisition before than this date"))
+
+geometry = click.option('--geometry',
+                        type=Geometry(),
+                        callback=check_geom,
+                        help=("A geojson geometry to search with. "
+                              "Can be a string, filename, or - for stdin."))
+
+
+def _strip_links(resource):
+    if isinstance(resource, dict):
+        resource.pop("_links", None)
+    return resource
+
+
+async def _output(result, pretty, include_links=False):
+    if asyncio.iscoroutine(result):
+        result = await result
+        if not include_links:
+            _strip_links(result)
+        echo_json(result, pretty)
+    else:
+        results = [_strip_links(r) async for r in result]
+        echo_json(results, pretty)
+
+
+@click.group()  # type: ignore
+@click.pass_context
+@click.option('-u',
+              '--base-url',
+              default=None,
+              help='Assign custom base Mosaics API URL.')
+def mosaics(ctx, base_url):
+    """Commands for interacting with the Mosaics API"""
+    ctx.obj['BASE_URL'] = base_url
+
+
+@mosaics.group()  # type: ignore
+def series():
+    """Commands for interacting with Mosaic Series through the Mosaics API"""
+
+
+@command(mosaics, name="contributions")
+@click.argument("name_or_id")
+@click.argument("quad")
+async def quad_contributions(ctx, name_or_id, quad, pretty):
+    '''Get contributing scenes for a quad in a mosaic specified by name or ID
+
+    Example:
+
+    planet mosaics contribution global_monthly_2025_04_mosaic 575-1300
+    '''
+    async with client(ctx) as cl:
+        item = await cl.get_quad(name_or_id, quad)
+        await _output(cl.get_quad_contributions(item), pretty)
+
+
+@command(mosaics, name="info")
+@click.argument("name_or_id", required=True)
+@include_links
+async def mosaic_info(ctx, name_or_id, pretty, links):
+    """Get information for a mosaic specified by name or ID
+
+    Example:
+
+    planet mosaics info global_monthly_2025_04_mosaic
+    """
+    async with client(ctx) as cl:
+        await _output(cl.get_mosaic(name_or_id), pretty, links)
+
+
+@command(mosaics, name="list")
+@name_contains
+@interval
+@acquired_gt
+@acquired_lt
+@include_links
+async def mosaics_list(ctx,
+                       name_contains,
+                       interval,
+                       acquired_gt,
+                       acquired_lt,
+                       pretty,
+                       links):
+    """List information for all available mosaics
+
+    Example:
+
+    planet mosaics list --name-contains global_monthly
+    """
+    async with client(ctx) as cl:
+        await _output(
+            cl.list_mosaics(name_contains=name_contains,
+                            interval=interval,
+                            acquired_gt=acquired_gt,
+                            acquired_lt=acquired_lt),
+            pretty,
+            links)
+
+
+@command(series, name="info")
+@click.argument("name_or_id", required=True)
+@include_links
+async def series_info(ctx, name_or_id, pretty, links):
+    """Get information for a series specified by name or ID
+
+    Example:
+
+    planet series info "Global Quarterly"
+    """
+    async with client(ctx) as cl:
+        await _output(cl.get_series(name_or_id), pretty, links)
+
+
+@command(series, name="list")
+@name_contains
+@interval
+@acquired_gt
+@acquired_lt
+@include_links
+async def series_list(ctx,
+                      name_contains,
+                      interval,
+                      acquired_gt,
+                      acquired_lt,
+                      pretty,
+                      links):
+    """List information for available series
+
+    Example:
+
+    planet mosaics series list --name-contains=Global
+    """
+    async with client(ctx) as cl:
+        await _output(
+            cl.list_series(
+                name_contains=name_contains,
+                interval=interval,
+                acquired_gt=acquired_gt,
+                acquired_lt=acquired_lt,
+            ),
+            pretty,
+            links)
+
+
+@command(series, name="list-mosaics")
+@click.argument("name_or_id", required=True)
+@click.option("--latest",
+              is_flag=True,
+              help=("Get the latest mosaic in the series"))
+@acquired_gt
+@acquired_lt
+@include_links
+async def list_series_mosaics(ctx,
+                              name_or_id,
+                              acquired_gt,
+                              acquired_lt,
+                              latest,
+                              pretty,
+                              links):
+    """List mosaics in a series specified by name or ID
+
+    Example:
+
+    planet mosaics series list-mosaics global_monthly_2025_04_mosaic
+    """
+    async with client(ctx) as cl:
+        await _output(
+            cl.list_series_mosaics(name_or_id,
+                                   acquired_gt=acquired_gt,
+                                   acquired_lt=acquired_lt,
+                                   latest=latest),
+            pretty,
+            links)
+
+
+@command(mosaics, name="search")
+@click.argument("name_or_id", required=True)
+@bbox
+@geometry
+@click.option("--summary",
+              is_flag=True,
+              help=("Get a count of how many quads would be returned"))
+@include_links
+async def list_quads(ctx, name_or_id, bbox, geometry, summary, pretty, links):
+    """Search quads in a mosaic specified by name or ID
+
+    Example:
+
+    planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41
+    """
+    async with client(ctx) as cl:
+        if summary:
+            result = cl.summarize_quads(name_or_id,
+                                        bbox=bbox,
+                                        geometry=geometry)
+        else:
+            result = cl.list_quads(name_or_id,
+                                   minimal=False,
+                                   bbox=bbox,
+                                   geometry=geometry)
+        await _output(result, pretty, links)
+
+
+@command(mosaics, name="download")
+@click.argument("name_or_id", required=True)
+@click.option('--output-dir',
+              help=('Directory for file download. Defaults to mosaic name'),
+              type=click.Path(exists=True,
+                              resolve_path=True,
+                              writable=True,
+                              file_okay=False))
+@bbox
+@geometry
+async def download(ctx, name_or_id, output_dir, bbox, geometry, **kwargs):
+    """Download quads from a mosaic by name or ID
+
+    Example:
+
+    planet mosaics search global_monthly_2025_04_mosaic --bbox -100,40,-100,41
+    """
+    quiet = ctx.obj['QUIET']
+    async with client(ctx) as cl:
+        await cl.download_quads(name_or_id,
+                                bbox=bbox,
+                                geometry=geometry,
+                                directory=output_dir,
+                                progress_bar=not quiet)
diff --git a/planet/cli/types.py b/planet/cli/types.py
index 6032fe70..c3168ea5 100644
--- a/planet/cli/types.py
+++ b/planet/cli/types.py
@@ -140,3 +140,14 @@ def convert(self, value, param, ctx) -> datetime:
                 self.fail(str(e))
 
         return value
+
+
+class BoundingBox(click.ParamType):
+    name = 'bbox'
+
+    def convert(self, val, param, ctx):
+        try:
+            xmin, ymin, xmax, ymax = map(float, val.split(','))
+        except (TypeError, ValueError):
+            raise click.BadParameter('Invalid bounding box')
+        return (xmin, ymin, xmax, ymax)
diff --git a/planet/clients/__init__.py b/planet/clients/__init__.py
index b1304d8d..7cbd0467 100644
--- a/planet/clients/__init__.py
+++ b/planet/clients/__init__.py
@@ -14,17 +14,23 @@
 # limitations under the License.
 from .data import DataClient
 from .features import FeaturesClient
+from .mosaics import MosaicsClient
 from .orders import OrdersClient
 from .subscriptions import SubscriptionsClient
 
 __all__ = [
-    'DataClient', 'FeaturesClient', 'OrdersClient', 'SubscriptionsClient'
+    'DataClient',
+    'FeaturesClient',
+    'MosaicsClient',
+    'OrdersClient',
+    'SubscriptionsClient'
 ]
 
 # Organize client classes by their module name to allow lookup.
 _client_directory = {
     'data': DataClient,
     'features': FeaturesClient,
+    'mosaics': MosaicsClient,
     'orders': OrdersClient,
     'subscriptions': SubscriptionsClient
 }
diff --git a/planet/clients/mosaics.py b/planet/clients/mosaics.py
new file mode 100644
index 00000000..5e18abb1
--- /dev/null
+++ b/planet/clients/mosaics.py
@@ -0,0 +1,497 @@
+# Copyright 2025 Planet Labs PBC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+import asyncio
+from pathlib import Path
+from typing import AsyncIterator, Optional, Sequence, Tuple, Type, TypeVar, Union, cast
+from planet.clients.base import _BaseClient
+from planet.constants import PLANET_BASE_URL
+from planet.exceptions import ClientError, MissingResource
+from planet.http import Session
+from planet.models import GeoInterface, Mosaic, Paged, Quad, Response, Series, StreamingBody
+from uuid import UUID
+
+BASE_URL = f'{PLANET_BASE_URL}/basemaps/v1'
+
+T = TypeVar("T")
+
+Number = Union[int, float]
+
+BBox = Sequence[Number]
+"""BBox is a rectangular area described by 2 corners
+where the positional meaning in the sequence is
+left, bottom, right, and top, respectively
+"""
+
+
+class _SeriesPage(Paged):
+    ITEMS_KEY = 'series'
+    NEXT_KEY = '_next'
+
+
+class _MosaicsPage(Paged):
+    ITEMS_KEY = 'mosaics'
+    NEXT_KEY = '_next'
+
+
+class _QuadsPage(Paged):
+    ITEMS_KEY = 'items'
+    NEXT_KEY = '_next'
+
+
+def _is_uuid(val: str) -> bool:
+    try:
+        UUID(val)
+        return True
+    except ValueError:
+        return False
+
+
+class MosaicsClient(_BaseClient):
+    """High-level asynchronous access to Planet's Mosaics API.
+
+    Example:
+        ```python
+        >>> import asyncio
+        >>> from planet import Session
+        >>>
+        >>> async def main():
+        ...     async with Session() as sess:
+        ...         cl = sess.client('mosaics')
+        ...         # use client here
+        ...
+        >>> asyncio.run(main())
+        ```
+    """
+
+    def __init__(self, session: Session, base_url: Optional[str] = None):
+        """
+        Parameters:
+            session: Open session connected to server.
+            base_url: The base URL to use. Defaults to production Mosaics
+                base url.
+        """
+        super().__init__(session, base_url or BASE_URL)
+
+    def _url(self, path: str) -> str:
+        return f"{self._base_url}/{path}"
+
+    async def _get_by_name(self, path: str, pager: Type[Paged],
+                           name: str) -> dict:
+        response = await self._session.request(
+            method='GET',
+            url=self._url(path),
+            params={
+                "name__is": name,
+            },
+        )
+        listing = response.json()[pager.ITEMS_KEY]
+        if len(listing):
+            return listing[0]
+        # mimic the response for 404 when search is empty
+        resource = "Mosaic"
+        if path == "series":
+            resource = "Series"
+        raise MissingResource('{"message":"%s Not Found: %s"}' %
+                              (resource, name))
+
+    async def _get_by_id(self, path: str, id: str) -> dict:
+        response = await self._session.request(method="GET",
+                                               url=self._url(f"{path}/{id}"))
+        return response.json()
+
+    async def _get(self, name_or_id: str, path: str,
+                   pager: Type[Paged]) -> dict:
+        if _is_uuid(name_or_id):
+            return await self._get_by_id(path, name_or_id)
+        return await self._get_by_name(path, pager, name_or_id)
+
+    async def _resolve_mosaic(self, mosaic: Union[Mosaic, str]) -> Mosaic:
+        if isinstance(mosaic, Mosaic):
+            return mosaic
+        return await self.get_mosaic(mosaic)
+
+    async def get_mosaic(self, name_or_id: str) -> Mosaic:
+        """Get the API representation of a mosaic by name or id.
+
+        Parameters:
+            name_or_id: The name or id of the mosaic
+        """
+        return Mosaic(await self._get(name_or_id, "mosaics", _MosaicsPage))
+
+    async def get_series(self, name_or_id: str) -> Series:
+        """Get the API representation of a series by name or id.
+
+        Parameters:
+            name_or_id: The name or id of the mosaic
+        """
+        return Series(await self._get(name_or_id, "series", _SeriesPage))
+
+    async def list_series(
+            self,
+            *,
+            name_contains: Optional[str] = None,
+            interval: Optional[str] = None,
+            acquired_gt: Optional[str] = None,
+            acquired_lt: Optional[str] = None) -> AsyncIterator[Series]:
+        """
+        List the series you have access to.
+
+        Example:
+
+        ```python
+        series = await client.list_series()
+        async for s in series:
+            print(s)
+        ```
+        """
+        params = {}
+        if name_contains:
+            params["name__contains"] = name_contains
+        if interval:
+            params["interval"] = interval
+        if acquired_gt:
+            params["acquired__gt"] = acquired_gt
+        if acquired_lt:
+            params["acquired__lt"] = acquired_lt
+        resp = await self._session.request(
+            method='GET',
+            url=self._url("series"),
+            params=params,
+        )
+        async for item in _SeriesPage(resp, self._session.request):
+            yield Series(item)
+
+    async def list_mosaics(
+        self,
+        *,
+        name_contains: Optional[str] = None,
+        interval: Optional[str] = None,
+        acquired_gt: Optional[str] = None,
+        acquired_lt: Optional[str] = None,
+    ) -> AsyncIterator[Mosaic]:
+        """
+        List the mosaics you have access to.
+
+        Example:
+
+        ```python
+        mosaics = await client.list_mosaics()
+        async for m in mosaics:
+            print(m)
+        ```
+        """
+        params = {}
+        if name_contains:
+            params["name__contains"] = name_contains
+        if interval:
+            params["interval"] = interval
+        if acquired_gt:
+            params["acquired__gt"] = acquired_gt
+        if acquired_lt:
+            params["acquired__lt"] = acquired_lt
+        resp = await self._session.request(
+            method='GET',
+            url=self._url("mosaics"),
+            params=params,
+        )
+        async for item in _MosaicsPage(resp, self._session.request):
+            yield Mosaic(item)
+
+    async def list_series_mosaics(
+        self,
+        /,
+        series: Union[Series, str],
+        *,
+        acquired_gt: Optional[str] = None,
+        acquired_lt: Optional[str] = None,
+        latest: bool = False,
+    ) -> AsyncIterator[Mosaic]:
+        """
+        List the mosaics in a series.
+
+        Example:
+
+        ```python
+        mosaics = await client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+        async for m in mosaics:
+            print(m)
+        ```
+        """
+        series_id = series
+        if isinstance(series, Series):
+            series_id = series["id"]
+        elif not _is_uuid(series):
+            series = Series(await self._get_by_name("series",
+                                                    _SeriesPage,
+                                                    series))
+            series_id = series["id"]
+        params = {}
+        if acquired_gt:
+            params["acquired__gt"] = acquired_gt
+        if acquired_lt:
+            params["acquired__lt"] = acquired_lt
+        if latest:
+            params["latest"] = "yes"
+        resp = await self._session.request(
+            method="GET",
+            url=self._url(f"series/{series_id}/mosaics"),
+            params=params,
+        )
+        async for item in _MosaicsPage(resp, self._session.request):
+            yield Mosaic(item)
+
+    async def summarize_quads(self,
+                              /,
+                              mosaic: Union[Mosaic, str],
+                              *,
+                              bbox: Optional[BBox] = None,
+                              geometry: Optional[Union[dict, GeoInterface]] = None) -> dict:
+        """
+        Get a summary of a quad list for a mosaic.
+
+        If the bbox or geometry is not provided, the entire list is considered.
+
+        Examples:
+
+        Get the total number of quads in the mosaic.
+
+        ```python
+        mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+        summary = await client.summarize_quads(mosaic)
+        print(summary["total_quads"])
+        ```
+        """
+        resp = await self._list_quads(mosaic, minimal=True, bbox=bbox, geometry=geometry, summary=True)
+        return resp.json()["summary"]
+
+    async def list_quads(self,
+                         /,
+                         mosaic: Union[Mosaic, str],
+                         *,
+                         minimal: bool = False,
+                         full_extent: bool = False,
+                         bbox: Optional[BBox] = None,
+                         geometry: Optional[Union[dict, GeoInterface]] = None) -> AsyncIterator[Quad]:
+        """
+        List the a mosaic's quads.
+
+        Parameters:
+            mosaic: the mosaic to list
+            minimal: if False, response includes full metadata
+            full_extent: if True, the mosaic's extent will be used to list
+            bbox: only quads intersecting the bbox will be listed
+            geometry: only quads intersecting the geometry will be listed
+
+        Raises:
+            ClientError: if `geometry`, `bbox` or `full_extent` is not specified.
+
+        Example:
+
+        List the quad at a single point (note the extent has the same corners)
+
+        ```python
+        mosaic = await client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+        quads = await client.list_quads(mosaic, bbox=[-100, 40, -100, 40])
+        async for q in quads:
+            print(q)
+        ```
+        """
+        if not any((geometry, bbox, full_extent)):
+            raise ClientError("one of: geometry, bbox, full_extent required")
+        resp = await self._list_quads(mosaic, minimal=minimal, bbox=bbox, geometry=geometry)
+        async for item in _QuadsPage(resp, self._session.request):
+            yield Quad(item)
+
+    async def _list_quads(self,
+                         /,
+                         mosaic: Union[Mosaic, str],
+                         *,
+                         minimal: bool = False,
+                         bbox: Optional[BBox] = None,
+                         geometry: Optional[Union[dict, GeoInterface]] = None,
+                         summary: bool = False) -> Response:
+        mosaic = await self._resolve_mosaic(mosaic)
+        if geometry:
+            if isinstance(geometry, GeoInterface):
+                geometry = geometry.__geo_interface__
+            resp = await self._quads_geometry(mosaic,
+                                              geometry,
+                                              minimal,
+                                              summary)
+        else:
+            if bbox is None:
+                xmin, ymin, xmax, ymax = cast(BBox, mosaic['bbox'])
+                search = (max(-180, xmin),
+                          max(-85, ymin),
+                          min(180, xmax),
+                          min(85, ymax))
+            else:
+                search = bbox
+            resp = await self._quads_bbox(mosaic, search, minimal, summary)
+        return resp
+
+    async def _quads_geometry(self,
+                              mosaic: Mosaic,
+                              geometry: dict,
+                              minimal: bool,
+                              summary: bool) -> Response:
+        params = {}
+        if minimal:
+            params["minimal"] = "true"
+        if summary:
+            params["summary"] = "true"
+            # this could be fixed in the API ...
+            # for a summary, we don't need to get any listings
+            # zero is ignored, but in case that gets rejected, just use 1
+            params["_page_size"] = 1
+        mosaic_id = mosaic["id"]
+        return await self._session.request(
+            method="POST",
+            url=self._url(f"mosaics/{mosaic_id}/quads/search"),
+            params=params,
+            json=geometry,
+        )
+
+    async def _quads_bbox(self,
+                          mosaic: Mosaic,
+                          bbox: BBox,
+                          minimal: bool,
+                          summary: bool) -> Response:
+        quads_template = mosaic["_links"]["quads"]
+        # this is fully qualified URL, so don't use self._url
+        url = quads_template.replace("{lx},{ly},{ux},{uy}",
+                                     ",".join([str(f) for f in bbox]))
+        # params will overwrite the templated query
+        if minimal:
+            url += "&minimal=true"
+        if summary:
+            url += "&summary=true"
+        return await self._session.request(
+            method="GET",
+            url=url,
+        )
+
+    async def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad:
+        """
+        Get a mosaic's quad information.
+
+        Example:
+
+        ```python
+        quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+        print(quad)
+        ```
+        """
+        mosaic = await self._resolve_mosaic(mosaic)
+        mosaic_id = mosaic["id"]
+        resp = await self._session.request(
+            method="GET",
+            url=self._url(f"mosaics/{mosaic_id}/quads/{quad_id}"),
+        )
+        return Quad(resp.json())
+
+    async def get_quad_contributions(self, quad: Quad) -> list[dict]:
+        """
+        Get a mosaic's quad information.
+
+        Example:
+
+        ```python
+        quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+        contributions = await client.get_quad_contributions(quad)
+        print(contributions)
+        ```
+        """
+        resp = await self._session.request(
+            "GET",
+            quad["_links"]["items"],
+        )
+        return resp.json()["items"]
+
+    async def download_quad(self,
+                            /,
+                            quad: Quad,
+                            *,
+                            directory: str = ".",
+                            overwrite: bool = False,
+                            progress_bar: bool = False):
+        """
+        Download a quad to a directory.
+
+        Example:
+
+        ```python
+        quad = await client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+        await client.download_quad(quad)
+        ```
+        """
+        url = quad["_links"]["download"]
+        Path(directory).mkdir(exist_ok=True, parents=True)
+        dest = Path(directory, quad["id"] + ".tif")
+        # this avoids a request to the download endpoint which would
+        # get counted as a download even if only the headers were read
+        # and the response content is ignored (like if when the file
+        # exists and overwrite is False)
+        if dest.exists() and not overwrite:
+            return
+        async with self._session.stream(method='GET', url=url) as resp:
+            await StreamingBody(resp).write(
+                dest,
+                # pass along despite our manual handling
+                overwrite=overwrite,
+                progress_bar=progress_bar)
+
+    async def download_quads(self,
+                             /,
+                             mosaic: Union[Mosaic, str],
+                             *,
+                             directory: Optional[str] = None,
+                             overwrite: bool = False,
+                             bbox: Optional[BBox] = None,
+                             geometry: Optional[Union[dict,
+                                                      GeoInterface]] = None,
+                             progress_bar: bool = False,
+                             concurrency: int = 4):
+        """
+        Download a mosaics' quads to a directory.
+
+        Raises:
+            ClientError: if `geometry` or `bbox` is not specified.
+
+        Example:
+
+        ```python
+        mosaic = await cl.get_mosaic(name)
+        client.download_quads(mosaic, bbox=(-100, 40, -100, 40))
+        ```
+        """
+        if not any((bbox, geometry)):
+            raise ClientError("bbox or geometry is required")
+        jobs = []
+        mosaic = await self._resolve_mosaic(mosaic)
+        directory = directory or mosaic["name"]
+        async for q in self.list_quads(mosaic,
+                                       minimal=True,
+                                       bbox=bbox,
+                                       geometry=geometry):
+            jobs.append(
+                self.download_quad(q,
+                                   directory=directory,
+                                   overwrite=overwrite,
+                                   progress_bar=progress_bar))
+            if len(jobs) == concurrency:
+                await asyncio.gather(*jobs)
+                jobs = []
+        await asyncio.gather(*jobs)
diff --git a/planet/models.py b/planet/models.py
index e7321e24..7e15cb7b 100644
--- a/planet/models.py
+++ b/planet/models.py
@@ -314,3 +314,15 @@ def ref(self):
 * an instance of a Planet Feature (e.g. the return value from `pl.features.get_items(collection_id)`)
 * an instance of a class that implements __geo_interface__ (Shapely, GeoPandas geometries)
 """
+
+
+class Mosaic(dict):
+    """The API representation of a Planet mosaic"""
+
+
+class Series(dict):
+    """The API representation of a Planet mosaic series"""
+
+
+class Quad(dict):
+    """The API representation of a mosaic quad"""
diff --git a/planet/sync/mosaics.py b/planet/sync/mosaics.py
new file mode 100644
index 00000000..bea28d51
--- /dev/null
+++ b/planet/sync/mosaics.py
@@ -0,0 +1,273 @@
+# Copyright 2025 Planet Labs PBC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+from typing import Iterator, Optional, TypeVar, Union
+from planet.clients.mosaics import BBox, MosaicsClient
+from planet.http import Session
+from planet.models import GeoInterface, Mosaic, Quad, Series
+
+T = TypeVar("T")
+
+
+class MosaicsAPI:
+
+    _client: MosaicsClient
+
+    def __init__(self, session: Session, base_url: Optional[str] = None):
+        """
+        Parameters:
+            session: Open session connected to server.
+            base_url: The base URL to use. Defaults to production Mosaics API
+                base url.
+        """
+        self._client = MosaicsClient(session, base_url)
+
+    def get_mosaic(self, name_or_id: str) -> Mosaic:
+        """Get the API representation of a mosaic by name or id.
+
+        Parameters:
+            name_or_id: The name or id of the mosaic
+        """
+        return self._client._call_sync(self._client.get_mosaic(name_or_id))
+
+    def get_series(self, name_or_id: str) -> Series:
+        """Get the API representation of a series by name or id.
+
+        Parameters:
+            name_or_id: The name or id of the mosaic
+        """
+        return self._client._call_sync(self._client.get_series(name_or_id))
+
+    def list_series(self,
+                    *,
+                    name_contains: Optional[str] = None,
+                    interval: Optional[str] = None,
+                    acquired_gt: Optional[str] = None,
+                    acquired_lt: Optional[str] = None) -> Iterator[Series]:
+        """
+        List the series you have access to.
+
+        Example:
+
+        ```python
+        series = client.list_series()
+        for s in series:
+            print(s)
+        ```
+        """
+        return self._client._aiter_to_iter(
+            self._client.list_series(name_contains=name_contains,
+                                     interval=interval,
+                                     acquired_gt=acquired_gt,
+                                     acquired_lt=acquired_lt))
+
+    def list_mosaics(
+        self,
+        *,
+        name_contains: Optional[str] = None,
+        interval: Optional[str] = None,
+        acquired_gt: Optional[str] = None,
+        acquired_lt: Optional[str] = None,
+    ) -> Iterator[Mosaic]:
+        """
+        List the mosaics you have access to.
+
+        Example:
+
+        ```python
+        mosaics = client.list_mosaics()
+        for m in mosaics:
+            print(m)
+        ```
+        """
+        return self._client._aiter_to_iter(
+            self._client.list_mosaics(
+                name_contains=name_contains,
+                interval=interval,
+                acquired_gt=acquired_gt,
+                acquired_lt=acquired_lt,
+            ))
+
+    def list_series_mosaics(
+        self,
+        /,
+        series: Union[Series, str],
+        *,
+        acquired_gt: Optional[str] = None,
+        acquired_lt: Optional[str] = None,
+        latest: bool = False,
+    ) -> Iterator[Mosaic]:
+        """
+        List the mosaics in a series.
+
+        Example:
+
+        ```python
+        mosaics = client.list_series_mosaics("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+        for m in mosaics:
+            print(m)
+        ```
+        """
+        return self._client._aiter_to_iter(
+            self._client.list_series_mosaics(
+                series,
+                acquired_gt=acquired_gt,
+                acquired_lt=acquired_lt,
+                latest=latest,
+            ))
+
+    def summarize_quads(self,
+                              /,
+                              mosaic: Union[Mosaic, str],
+                              *,
+                              bbox: Optional[BBox] = None,
+                              geometry: Optional[Union[dict, GeoInterface]] = None) -> dict:
+        """
+        Get a summary of a quad list for a mosaic.
+
+        If the bbox or geometry is not provided, the entire list is considered.
+
+        Examples:
+
+        Get the total number of quads in the mosaic.
+
+        ```python
+        mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+        summary = client.summarize_quads(mosaic)
+        print(summary["total_quads"])
+        ```
+        """
+        return self._client._call_sync(self._client.summarize_quads(mosaic, bbox=bbox, geometry=geometry))
+
+    def list_quads(self,
+                   /,
+                   mosaic: Union[Mosaic, str],
+                   *,
+                   minimal: bool = False,
+                   full_extent: bool = False,
+                   bbox: Optional[BBox] = None,
+                   geometry: Optional[Union[dict, GeoInterface]] = None) -> Iterator[Quad]:
+        """
+        List the a mosaic's quads.
+
+
+        Parameters:
+            mosaic: the mosaic to list
+            minimal: if False, response includes full metadata
+            full_extent: if True, the mosaic's extent will be used to list
+            bbox: only quads intersecting the bbox will be listed
+            geometry: only quads intersecting the geometry will be listed
+
+        Raises:
+            ValueError: if `geometry`, `bbox` or `full_extent` is not specified.
+
+        Example:
+
+        ```python
+        mosaic = client.get_mosaic("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5")
+        quads = client.list_quads(mosaic)
+        for q in quads:
+            print(q)
+        ```
+        """
+        return self._client._aiter_to_iter(
+            self._client.list_quads(
+                mosaic,
+                minimal=minimal,
+                full_extent=full_extent,
+                bbox=bbox,
+                geometry=geometry,
+            ))
+
+    def get_quad(self, mosaic: Union[Mosaic, str], quad_id: str) -> Quad:
+        """
+        Get a mosaic's quad information.
+
+        Example:
+
+        ```python
+        quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+        print(quad)
+        ```
+        """
+        return self._client._call_sync(self._client.get_quad(mosaic, quad_id))
+
+    def get_quad_contributions(self, quad: Quad) -> list[dict]:
+        """
+        Get a mosaic's quad information.
+
+        Example:
+
+        ```python
+        quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+        contributions = client.get_quad_contributions(quad)
+        print(contributions)
+        ```
+        """
+        return self._client._call_sync(
+            self._client.get_quad_contributions(quad))
+
+    def download_quad(self,
+                      /,
+                      quad: Quad,
+                      *,
+                      directory: str = ".",
+                      overwrite: bool = False,
+                      progress_bar: bool = False):
+        """
+        Download a quad to a directory.
+
+        Example:
+
+        ```python
+        quad = client.get_quad("d5098531-aa4f-4ff9-a9d5-74ad4a6301e5", "1234-5678")
+        client.download_quad(quad)
+        ```
+        """
+        self._client._call_sync(
+            self.download_quad(quad,
+                               directory=directory,
+                               overwrite=overwrite,
+                               progress_bar=progress_bar))
+
+    def download_quads(self,
+                       /,
+                       mosaic: Union[Mosaic, str],
+                       *,
+                       directory: Optional[str] = None,
+                       overwrite: bool = False,
+                       bbox: Optional[BBox] = None,
+                       geometry: Optional[Union[dict, GeoInterface]] = None,
+                       progress_bar: bool = False,
+                       concurrency: int = 4):
+        """
+        Download a mosaics' quads to a directory.
+
+        Example:
+
+        ```python
+        mosaic = cl.get_mosaic(name)
+        client.download_quads(mosaic, bbox=(-100, 40, -100, 41))
+        ```
+        """
+        return self._client._call_sync(
+            self._client.download_quads(
+                mosaic,
+                directory=directory,
+                overwrite=overwrite,
+                bbox=bbox,
+                geometry=geometry,
+                progress_bar=progress_bar,
+                concurrency=concurrency,
+            ))
diff --git a/tests/integration/test_mosaics_api.py b/tests/integration/test_mosaics_api.py
new file mode 100644
index 00000000..a6a9a05a
--- /dev/null
+++ b/tests/integration/test_mosaics_api.py
@@ -0,0 +1,41 @@
+import asyncio
+import functools
+import inspect
+from unittest.mock import patch
+from planet.sync.mosaics import MosaicsAPI
+from tests.integration import test_mosaics_cli
+import pytest
+
+from concurrent.futures import ThreadPoolExecutor
+
+
+def async_wrap(api):
+    pool = ThreadPoolExecutor()
+
+    def make_async(fn):
+
+        @functools.wraps(fn)
+        async def wrapper(*args, **kwargs):
+            future = pool.submit(fn, *args, **kwargs)
+            res = await asyncio.wrap_future(future)
+            if inspect.isgenerator(res):
+                return list(res)
+            return res
+
+        return wrapper
+
+    members = inspect.getmembers(api, inspect.isfunction)
+    funcs = {m[0]: make_async(m[1]) for m in members if m[0][0] != "_"}
+    funcs["__init__"] = getattr(api, "__init__")
+    funcs["_pool"] = pool
+    return type("AsyncAPI", (object, ), funcs)
+
+
+# @pytest.mark.skip
+@pytest.mark.parametrize(
+    "tc", [pytest.param(tc, id=tc.id) for tc in test_mosaics_cli.test_cases])
+def test_api(tc):
+    api = async_wrap(MosaicsAPI)
+    with patch('planet.cli.mosaics.MosaicsClient', api):
+        test_mosaics_cli.run_test(tc)
+        api._pool.shutdown()
diff --git a/tests/integration/test_mosaics_cli.py b/tests/integration/test_mosaics_cli.py
new file mode 100644
index 00000000..d549a1af
--- /dev/null
+++ b/tests/integration/test_mosaics_cli.py
@@ -0,0 +1,410 @@
+from dataclasses import dataclass
+from pathlib import Path
+import json
+from typing import Optional
+import httpx
+import pytest
+
+import respx
+from click.testing import CliRunner
+
+from planet.cli import cli
+
+baseurl = "http://basemaps.com/v1/"
+
+uuid = "09462e5a-2af0-4de3-a710-e9010d8d4e58"
+
+
+def url(path: str) -> str:
+    return baseurl + path
+
+
+def request(path: str,
+            json,
+            method="GET",
+            status=200,
+            headers=None,
+            stream=None):
+
+    def go():
+        respx.request(method,
+                      url(path)).return_value = httpx.Response(status,
+                                                               json=json,
+                                                               headers=headers,
+                                                               stream=stream)
+
+    return go
+
+
+def quad_item_downloads(cnt):
+    return [{
+        "_links": {
+            "download": url(f"mosaics/download-a-quad/{i}")
+        },
+        "id": f"456-789{i}"
+    } for i in range(cnt)]
+
+
+def quad_item_download_requests(cnt):
+    return [
+        request(
+            f"mosaics/download-a-quad/{i}",
+            None,
+            stream=stream(),
+            headers={
+                "Content-Length": "100",
+            }) for i in range(cnt)
+    ]
+
+
+async def stream():
+    yield bytes("data" * 25, encoding="ascii")
+
+
+@dataclass
+class CLITestCase:
+    id: str
+    command: list[str]
+    args: list[str]
+    requests: list
+    exit_code: int = 0
+    output: Optional[dict] = None
+    expect_files: Optional[list[str]] = None
+    exception: Optional[str] = None
+
+
+info_cases = [
+    CLITestCase(id="info",
+                command=["info"],
+                args=[uuid],
+                output={"name": "a mosaic"},
+                requests=[
+                    request(f"mosaics/{uuid}", {"name": "a mosaic"}),
+                ]),
+    CLITestCase(id="info not exist by uuid",
+                command=["info"],
+                args=[uuid],
+                output='Error: {"message":"Mosaic Not Found: fff"}\n',
+                exit_code=1,
+                requests=[
+                    request(f"mosaics/{uuid}",
+                            {"message": "Mosaic Not Found: fff"},
+                            status=404),
+                ]),
+    CLITestCase(id="info not exist by name",
+                command=["info"],
+                args=["fff"],
+                output='Error: {"message":"Mosaic Not Found: fff"}\n',
+                exit_code=1,
+                requests=[request("mosaics?name__is=fff", {"mosaics": []})]),
+]
+
+list_mosaic_cases = [
+    CLITestCase(id="list",
+                command=["list"],
+                args=[],
+                output=[{
+                    "name": "a mosaic"
+                }],
+                requests=[
+                    request("mosaics", {"mosaics": [{
+                        "name": "a mosaic"
+                    }]}),
+                ]),
+    CLITestCase(
+        id="list with filters",
+        command=["list"],
+        args=[
+            "--name-contains",
+            "name",
+            "--interval",
+            "1 day",
+            "--acquired_lt",
+            "2025-05-19",
+            "--acquired_gt",
+            "2024-05-19"
+        ],
+        output=[{
+            "name": "a mosaic"
+        }],
+        requests=[
+            request(
+                "mosaics?name__contains=name&interval=1+day&acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00",
+                {"mosaics": [{
+                    "name": "a mosaic"
+                }]}),
+        ]),
+]
+
+series_info_cases = [
+    CLITestCase(
+        id="series info",
+        command=["series", "info"],
+        args=["Global Monthly"],
+        output={"id": "123"},
+        requests=[
+            request("series?name__is=Global+Monthly",
+                    {"series": [{
+                        "id": "123"
+                    }]})
+        ],
+    ),
+    CLITestCase(
+        id="series info by name does not exist",
+        command=["series", "info"],
+        args=["non-existing-series"],
+        output='Error: {"message":"Series Not Found: non-existing-series"}\n',
+        exit_code=1,
+        requests=[
+            request("series?name__is=non-existing-series", {"series": []})
+        ],
+    ),
+    CLITestCase(
+        id="series info by uuid does not exist",
+        command=["series", "info"],
+        args=[uuid],
+        output='Error: {"message":"Series Not Found: fff"}\n',
+        exit_code=1,
+        requests=[
+            request(f"series/{uuid}", {"message": "Series Not Found: fff"},
+                    status=404),
+        ],
+    ),
+]
+
+list_series_cases = [
+    CLITestCase(id="series list",
+                command=["series", "list"],
+                args=[],
+                output=[{
+                    "name": "a series"
+                }],
+                requests=[
+                    request("series", {"series": [{
+                        "name": "a series"
+                    }]}),
+                ]),
+    CLITestCase(
+        id="series list filters",
+        command=["series", "list"],
+        args=[
+            "--name-contains",
+            "name",
+            "--interval",
+            "1 day",
+            "--acquired_lt",
+            "2025-05-19",
+            "--acquired_gt",
+            "2024-05-19"
+        ],
+        output=[{
+            "name": "a series"
+        }],
+        requests=[
+            request(
+                "series?name__contains=name&interval=1+day&acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00",
+                {"series": [{
+                    "name": "a series"
+                }]}),
+        ]),
+    CLITestCase(id="series list-mosaics",
+                command=["series", "list-mosaics"],
+                args=[uuid],
+                output=[{
+                    "name": "a mosaic"
+                }],
+                requests=[
+                    request(
+                        "series/09462e5a-2af0-4de3-a710-e9010d8d4e58/mosaics",
+                        {"mosaics": [{
+                            "name": "a mosaic"
+                        }]}),
+                ]),
+    CLITestCase(
+        id="series list-mosaics filters",
+        command=["series", "list-mosaics"],
+        args=[
+            "Some Series",
+            "--acquired_lt",
+            "2025-05-19",
+            "--acquired_gt",
+            "2024-05-19",
+            "--latest"
+        ],
+        output=[{
+            "name": "a mosaic"
+        }],
+        requests=[
+            request("series?name__is=Some+Series", {"series": [{
+                "id": "123"
+            }]}),
+            request(
+                "series/123/mosaics?acquired__gt=2024-05-19+00%3A00%3A00&acquired__lt=2025-05-19+00%3A00%3A00&latest=yes",
+                {"mosaics": [{
+                    "name": "a mosaic"
+                }]}),
+        ]),
+]
+
+search_cases = [
+    CLITestCase(
+        id="mosaics search bbox",
+        command=["search"],
+        args=[uuid, "--bbox", "-100,40,-100,40"],
+        output=[{
+            "id": "455-1272"
+        }],
+        requests=[
+            request(
+                f"mosaics/{uuid}",
+                {
+                    "_links": {
+                        "quads": url(
+                            "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox={lx},{ly},{ux},{uy}"
+                        )
+                    }
+                }),
+            request(
+                "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0",
+                {"items": [{
+                    "id": "455-1272"
+                }]}),
+        ]),
+    CLITestCase(
+        id="mosaics search bbox summary",
+        command=["search"],
+        args=[uuid, "--bbox", "-100,40,-100,40", "--summary"],
+        output={
+            "total_quads": 1234
+        },
+        requests=[
+            request(
+                f"mosaics/{uuid}",
+                {
+                    "_links": {
+                        "quads": url(
+                            "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox={lx},{ly},{ux},{uy}"
+                        )
+                    }
+                }),
+            request(
+                "mosaics/09462e5a-2af0-4de3-a710-e9010d8d4e58/quads?bbox=-100.0,40.0,-100.0,40.0&minimal=true&summary=true",
+                {
+                    # note this gets stripped from expected output
+                    "items": [{
+                        "id": "455-1272"
+                    }],
+                    "summary": {
+                        "total_quads": 1234
+                    }
+                }),
+        ]),
+]
+
+download_cases = [
+    CLITestCase(
+        id="mosaics download bbox",
+        command=["download"],
+        args=[uuid, "--bbox", '-100,40,-100,40'],
+        requests=[
+            request(
+                f"mosaics/{uuid}",
+                {
+                    "id": "123",
+                    "name": "a mosaic",
+                    "_links": {
+                        "quads": url(
+                            "mosaics/123/quads?bbox={lx},{ly},{ux},{uy}")
+                    }
+                }),
+            request(
+                "mosaics/123/quads?bbox=-100.0,40.0,-100.0,40.0&minimal=true",
+                {"items": quad_item_downloads(1)}),
+            *quad_item_download_requests(1),
+        ],
+        expect_files=[
+            "a mosaic/456-7890.tif",
+        ]),
+    CLITestCase(
+        id="mosaics download geometry",
+        command=["download"],
+        args=[uuid, "--geometry", '{"type": "Point", "coordinates": [0,0]}'],
+        requests=[
+            request(f"mosaics/{uuid}", {
+                "id": "123", "name": "a mosaic"
+            }),
+            request("mosaics/123/quads/search?minimal=true", {},
+                    status=302,
+                    method="POST",
+                    headers={"Location": url("mosaics/search-link")}),
+            request("mosaics/search-link", {"items": quad_item_downloads(5)}),
+            *quad_item_download_requests(5),
+        ],
+        expect_files=[
+            "a mosaic/456-7890.tif",
+            "a mosaic/456-7891.tif",
+            "a mosaic/456-7892.tif",
+            "a mosaic/456-7893.tif",
+            "a mosaic/456-7894.tif",
+        ])
+]
+
+other_cases = [
+    CLITestCase(
+        id="quad contributions",
+        command=["contributions"],
+        args=["mosaic-name", "quad-id"],
+        output=[{
+            "link": "https://api.planet.com/some/item"
+        }],
+        requests=[
+            request("mosaics?name__is=mosaic-name",
+                    {"mosaics": [{
+                        "id": "123"
+                    }]}),
+            request(
+                "mosaics/123/quads/quad-id",
+                {"_links": {
+                    "items": url("mosaics/123/quads/quad-id/items")
+                }}),
+            request("mosaics/123/quads/quad-id/items",
+                    {"items": [{
+                        "link": "https://api.planet.com/some/item"
+                    }]})
+        ]),
+]
+
+test_cases = info_cases + series_info_cases + list_mosaic_cases + list_series_cases + search_cases + download_cases + other_cases
+
+
+@pytest.mark.parametrize("tc",
+                         [pytest.param(tc, id=tc.id) for tc in test_cases])
+def test_cli(tc: CLITestCase):
+    run_test(tc)
+
+
+@respx.mock
+def run_test(tc: CLITestCase):
+    runner = CliRunner()
+    with runner.isolated_filesystem() as folder:
+        for r in tc.requests:
+            r()
+
+        args = ["mosaics", "-u", baseurl] + tc.command + tc.args
+        result = runner.invoke(cli.main, args=args)
+        # result.exception may be SystemExit which we want to ignore
+        # but if we don't raise a "true error" exception, there's no
+        # stack trace, making it difficult to diagnose
+        if result.exception and tc.exit_code == 0:
+            raise result.exception
+        assert result.exit_code == tc.exit_code, result.output
+        if tc.output:
+            try:
+                # error output (always?) not JSON
+                output = json.loads(result.output)
+            except json.JSONDecodeError:
+                output = result.output
+            assert output == tc.output
+        if tc.expect_files:
+            for f in tc.expect_files:
+                assert Path(folder, f).exists(), f