This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Recipes (examples)

Complete, copy-and-run SDK scripts grouped by domain area

Every example in this section is a complete script that uses argparse. Pass --help to any script to see all options. The same files live in cvat-sdk/examples/.

Shared conventions:

  • Every recipe takes --host and --token. Create a token in the CVAT UI under Profile -> Security. Wrap values that contain URL punctuation in single quotes, e.g. --host 'https://app.cvat.ai'. auth_profile.py and auth_cli.py show alternative sign-in flows.
  • Recipes that create resources keep them and print their ids and UI links. Pass --cleanup to delete what the script created (never the sources it read).
  • Recipes that inspect or export take an existing resource id (--project-id, --task-id), so they work directly against your data.
  • List-valued options accept multiple values, e.g. --labels car person.
  • Missing arguments exit with a friendly message; SDK errors surface as normal Python tracebacks.

All examples are tested in the latest SDK version.

Topics

  • Authenticateauth_token.py, auth_profile.py, auth_cli.py
  • Projects — create/list, backup, restore, dataset export
  • Tasks — create from a bucket, bulk-create in a project, inspect and export
  • Jobs — list jobs, round-robin assignment, batch-advance stages
  • Cloud storage — attach an S3-compatible bucket

1 - Authenticate a client

Copy-and-run auth recipes: PAT (recommended), saved profiles, and the CLI-compatible argument set

Three recipes: auth_token.py is the recommended PAT path, auth_profile.py signs in from a saved profile with no secret in your code, and auth_cli.py wires up the shared cvat-cli argument set (--server-host, --auth, --profile, …) so your scripts feel like an extension of the CLI.

Connect with a Personal Access Token

Opens an authenticated client with a PAT, prints the server version, and prints who you are — a quick sanity check any script can copy.

Flag Required Meaning
--host yes Server URL, e.g. 'https://app.cvat.ai'
--token yes Token created in the CVAT UI (Profile -> Security)
python auth_token.py --host 'https://app.cvat.ai' --token '<your token>'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Connect to CVAT with a Personal Access Token (PAT) — the recommended way.

Steps:
  1. Open an authenticated client.
  2. Print the server version.
  3. Print who you are authenticated as (a quick sanity check for scripts).

Usage (run ``python auth_token.py --help`` for the full list of options):
  python auth_token.py --host 'https://app.cvat.ai' --token '<your token>'

Create a token in the CVAT UI under Profile -> Security.
"""

import argparse

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (create one in the CVAT UI: Profile -> Security)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        print("Server version:", client.get_server_version())
        me = client.users.retrieve_current_user()
        print(f"Authenticated as {me.username} (id={me.id})")


if __name__ == "__main__":
    main()

Sign in from a saved profile

Uses a saved CLI profile so no secret lives in the code. Create a profile once with cvat-cli; then any script can pick it by name or fall back to the default profile.

Create a profile once:

cvat-cli --server-host 'https://app.cvat.ai' profile create --name app --set-default
Flag Required Meaning
--profile no Name of a saved profile; omit to use the default profile
python auth_profile.py --profile app
python auth_profile.py               # uses the default profile

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Authenticate without putting a token in your code: use a saved profile.

Create a profile once on the command line, then any script can use it:

  cvat-cli --server-host 'https://app.cvat.ai' profile create --name app --set-default

Steps:
  1. If --profile is passed, use that profile; otherwise use the default profile.
  2. Print who you are authenticated as.

Usage (run ``python auth_profile.py --help`` for the full list of options):
  python auth_profile.py --profile app
  python auth_profile.py               # uses the default profile
"""

import argparse
import sys

from cvat_sdk import make_client_from_profile
from cvat_sdk.core.auth import AuthStore


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument(
        "--profile", help="name of a saved profile; omit to use the default profile"
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    store = AuthStore()
    if args.profile:
        profile = store.get_profile(args.profile)
        if profile is None:
            sys.exit(f"Profile {args.profile!r} not found. Create it with cvat-cli.")
        print(f"Using profile {args.profile!r}")
    else:
        default = store.get_default_profile()
        if default is None:
            sys.exit(
                "No default profile configured. Create one with:\n"
                "    cvat-cli --server-host 'https://app.cvat.ai' profile create"
                " --name app --set-default"
            )
        name, profile = default
        print(f"Using default profile {name!r}")

    with make_client_from_profile(profile) as client:
        me = client.users.retrieve_current_user()
        print(f"Authenticated as {me.username} (id={me.id})")


if __name__ == "__main__":
    main()

Build a CLI-compatible script

Reuses cvat-cli’s shared auth arguments (--server-host, --server-port, --auth, --profile, --insecure, --organization) with configure_client_auth_arguments, then hands the parsed namespace to make_client_from_cli, which picks the right factory (profile / PAT / password) from the arguments. This is the go-to pattern when your script should feel like an extension of cvat-cli.

Flag Required Meaning
--server-host fallback Server URL when not using a profile
--auth fallback USER:PASS (deprecated password sign-in) or USER — see cvat-cli
--profile fallback Named saved profile; falls back to the default profile if no host/auth
--insecure, --organization, --server-port no Reused from cvat-cli’s shared arg set

Also honors CVAT_ACCESS_TOKEN / CVAT_PASSWORD environment variables the same way cvat-cli does.

python auth_cli.py --profile app
python auth_cli.py --server-host 'https://app.cvat.ai'          # uses CVAT_ACCESS_TOKEN env
python auth_cli.py --server-host 'https://app.cvat.ai' --auth me:secret

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Build a CLI-compatible script that reuses ``cvat-cli``'s auth argument set:
``--server-host`` / ``--server-port`` / ``--auth`` / ``--profile`` / ``--insecure`` / ``--organization``.

This is the go-to pattern when your script should feel like an extension of
``cvat-cli`` — it accepts the same flags, honors the ``CVAT_ACCESS_TOKEN`` and
``PASS`` env variables, and resolves profiles the same way (explicit
``--profile``, else the default profile if no host/auth is passed).

Steps:
  1. Register the shared auth flags with ``configure_client_auth_arguments()``.
  2. Add your own script-specific arguments on top.
  3. Hand the parsed namespace to ``make_client_from_cli()`` to create a server API client object.

Usage (run ``python auth_cli.py --help`` for the full list of options):
  python auth_cli.py --profile app

  # export CVAT_ACCESS_TOKEN='<token>'  # for macOS/Linux
  # $env:CVAT_ACCESS_TOKEN = "<token>"  # for PowerShell
  python auth_cli.py --server-host 'https://app.cvat.ai'

  python auth_cli.py --server-host 'https://app.cvat.ai' --auth me:secret
"""

import argparse

from cvat_sdk import make_client_from_cli
from cvat_sdk.core.auth import configure_client_auth_arguments


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    configure_client_auth_arguments(parser)
    # Add your script's own arguments here, e.g.
    # parser.add_argument("--task-id", type=int, required=True)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client_from_cli(args) as client:
        me = client.users.retrieve_current_user()
        print(f"Authenticated as {me.username} (id={me.id})")


if __name__ == "__main__":
    main()

Notes:

  • Personal Access Tokens are the recommended path. Password sign-in (via --auth USER:PASS) is a deprecated fallback that will be removed in a future release.
  • Full recipes: auth_token.py, auth_profile.py, auth_cli.py.

2 - Project recipes

Create/list, backup, restore, dataset export — one recipe per file

Five recipes cover the project lifecycle: project_create_and_list.py for the common CRUD path, project_add_labels.py for extending an existing project’s label schema, project_backup.py and project_restore.py for portable copies, and project_export_dataset.py for dataset export (local + cloud). For a CSV overview of a project’s jobs, see job_list.py --project-id --csv in the job recipes.

Create, list, filter, retrieve, rename

Creates a project with labels, then lists all projects, filters by name, retrieves by id, and renames it. Pass --cleanup to delete it at the end.

Flag Required Meaning
--host yes Server URL, e.g. 'https://app.cvat.ai'
--token yes Personal Access Token
--name no Project name (default 'Example project')
--labels no Label names, space-separated (default car person)
--cleanup no Delete the created project at the end
python project_create_and_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --name 'My project' --labels car person

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create a project with labels, then list, filter, retrieve, and rename it.

Steps:
  1. Create a project with a simple label schema.
  2. List all projects visible to you (pagination is handled by the SDK).
  3. Filter projects by a name substring.
  4. Retrieve one project by id and read its labels.
  5. Rename it.
  6. Optionally delete it (--cleanup).

Usage (run ``python project_create_and_list.py --help`` for the full list of options):
  python project_create_and_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --name 'My project' --labels car person
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--name", default="Example project", help="project name (default: '%(default)s')"
    )
    parser.add_argument(
        "--labels",
        nargs="+",
        default=["car", "person"],
        help="label names (default: %(default)s)",
    )
    parser.add_argument(
        "--cleanup", action="store_true", help="delete the created project at the end"
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # 1. Create a project with labels
        project = client.projects.create(
            models.ProjectWriteRequest(
                name=args.name,
                labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
            )
        )
        print(f"Created project {project.id}: {args.host}/projects/{project.id}")

        # 2. List all projects
        projects = client.projects.list()
        print(f"Projects visible to you: {len(projects)}")

        # 3. Filter by name substring
        matches = client.projects.list(filter=F.name.contains(args.name))
        print(f"Projects with {args.name!r} in the name: {[p.id for p in matches]}")

        # 4. Retrieve by id
        fetched = client.projects.retrieve(project.id)
        print(f"Project {fetched.id} labels: {[label.name for label in fetched.get_labels()]}")

        # 5. Rename
        renamed = fetched.update(models.PatchedProjectWriteRequest(name=f"{args.name} (renamed)"))
        print(f"Renamed to: {renamed.name}")

        # 6. Opt-in cleanup
        if args.cleanup:
            renamed.remove()
            print(f"Deleted project {project.id}")
        else:
            print("Keeping the project; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Add labels to an existing project

Adds labels — optionally with selectable attributes — to a project that already exists. Labels that are already there are skipped, so the recipe is safe to re-run. The tasks inside the project take their labels from the project itself, so they all pick up the change.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to extend
--labels yes Label names to add, space-separated
--attr LABEL NAME VALUE [...] no Selectable attribute for one of the --labels; repeat for more
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --labels car person
python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --labels car --attr car color red green blue

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Add labels, optionally with selectable attributes, to an existing project.

Steps:
  1. Retrieve the project and read the labels it already has; the requested
     labels that already exist are skipped, so the script is safe to re-run.
  2. Attach the --attr definitions to their new labels.
  3. Send one project update with the new labels. Labels of the tasks inside
     the project come from the project itself, so they all pick up the change.

Usage (run ``python project_add_labels.py --help`` for the full list of options):
  python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --labels car person
  python project_add_labels.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --labels car --attr car color red green blue
"""

import argparse

from cvat_sdk import make_client, models


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
    )
    parser.add_argument(
        "--labels", nargs="+", metavar="NAME", required=True, help="label names to add"
    )
    parser.add_argument(
        "--attr",
        nargs="+",
        action="append",
        default=[],
        metavar=("LABEL NAME", "VALUE"),
        help="selectable attribute for one of the --labels: label name, attribute "
        "name, then its values (repeat --attr for more attributes)",
    )
    args = parser.parse_args()
    for attr in args.attr:
        if len(attr) < 3:
            parser.error("--attr needs a label, an attribute name, and at least one value")
        if attr[0] not in args.labels:
            parser.error(f"--attr refers to label {attr[0]!r}, which is not in --labels")
    return args


def main() -> None:
    args = parse_args()
    attributes_per_label: dict[str, list[models.AttributeRequest]] = {}
    for label_name, attribute_name, *values in args.attr:
        attributes_per_label.setdefault(label_name, []).append(
            models.AttributeRequest(
                name=attribute_name,
                input_type=models.InputTypeEnum("select"),
                values=values,
                default_value=values[0],
                mutable=True,
            )
        )

    with make_client(args.host, access_token=args.token) as client:
        project = client.projects.retrieve(args.project_id)
        existing = {label.name for label in project.get_labels()}

        new_labels = []
        for name in args.labels:
            if name in existing:
                print(f"Label {name!r} already exists, skipping")
                continue
            new_labels.append(
                models.PatchedLabelRequest(name=name, attributes=attributes_per_label.get(name, []))
            )

        if new_labels:
            project.update(models.PatchedProjectWriteRequest(labels=new_labels))
        print(
            f"Added {len(new_labels)} labels to project {project.id}: "
            f"{', '.join(label.name for label in new_labels) or '-'}"
        )
        print(f"Project {project.id} labels: {[label.name for label in project.get_labels()]}")


if __name__ == "__main__":
    main()

Back up a project

Downloads a full project backup zip — tasks, jobs, annotations, and settings. Pair with project_restore.py to migrate or clone.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to back up
--output no Destination file (default project_<id>_backup.zip)
python project_backup.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 42

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Download a backup zip of an existing project.

A backup contains the project's tasks, jobs, annotations, and settings. Pair
this recipe with project_restore.py to migrate or clone a project.

Steps:
  1. Retrieve the project by id.
  2. Download its backup to --output (default: project_<id>_backup.zip).

Usage (run ``python project_backup.py --help`` for the full list of options):
  python project_backup.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 42
"""

import argparse
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 42"
    )
    parser.add_argument(
        "--output",
        type=Path,
        help="destination file path (default: project_<id>_backup.zip)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        project = client.projects.retrieve(args.project_id)
        output = args.output or Path(f"project_{project.id}_backup.zip")
        project.download_backup(output)
        print(f"Backed up project {project.id} to {output.resolve()}")


if __name__ == "__main__":
    main()

Restore a project

Restores a project from a backup zip as a brand-new project. Pass --cleanup to delete the restored copy afterwards — useful when validating a backup file.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--backup yes Path to a project backup zip
--cleanup no Delete the restored copy (never touches the backup file)
python project_restore.py --host 'https://app.cvat.ai' --token '<your token>' \
    --backup './project_42_backup.zip'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Restore a project from a backup zip as a new project.

Pair with project_backup.py to migrate or clone a project.

Steps:
  1. Restore --backup as a brand-new project.
  2. Optionally delete the restored copy (--cleanup) — useful when testing a
     backup file.

Usage (run ``python project_restore.py --help`` for the full list of options):
  python project_restore.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --backup './project_42_backup.zip'
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument("--backup", type=Path, required=True, help="path to a project backup zip")
    parser.add_argument(
        "--cleanup",
        action="store_true",
        help="delete the restored project at the end (never touches the source backup)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if not args.backup.is_file():
        sys.exit(f"--backup {args.backup} does not exist")

    with make_client(args.host, access_token=args.token) as client:
        restored = client.projects.create_from_backup(args.backup)
        print(f"Restored a copy as project {restored.id}: {args.host}/projects/{restored.id}")

        if args.cleanup:
            restored.remove()
            print(f"Deleted restored project {restored.id}")
        else:
            print("Keeping the restored project; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Export a project’s tasks individually (local + cloud)

Exports each task in a project as its own dataset, both to a local zip and straight to a registered cloud storage. By default every task is exported; pass --task-id to export only a specific subset. Validates the format name against the server’s list before starting.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to export
--cloud-storage-id yes Registered cloud storage id (see cloud_storage_register.py)
--export-format no Exporter name (default 'COCO 1.0')
--task-id no Task ids to export, space-separated (default: every task in the project)
python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 42 --cloud-storage-id 7 --export-format 'COCO 1.0'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Export a project's tasks individually, without images, to local zips AND to
a registered cloud storage.

By default every task in the project is exported; pass --task-id to export
only a specific subset. This is the SDK-only stand-in for what could become a
bulk per-task export command in cvat-cli.

Steps:
  1. Fetch the server's export format list and validate --export-format.
  2. Resolve which tasks to export: --task-id filters to a subset of the
     project's tasks; omit it to export every task in the project.
  3. For each task: export to task_<id>_dataset.zip in the current directory,
     then export the same dataset straight to the cloud storage (no local
     download).

Usage (run ``python project_export_dataset.py --help`` for the full list of options):
  # every task in the project
  python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 42 --cloud-storage-id 7 --export-format 'COCO 1.0'

  # only tasks 10 and 11
  python project_export_dataset.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 42 --cloud-storage-id 7 --task-id 10 11
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client
from cvat_sdk.core.proxies.types import Location


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 42"
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        required=True,
        help="a registered cloud storage id (see cloud_storage_register.py)",
    )
    parser.add_argument(
        "--export-format",
        default="COCO 1.0",
        help="exporter name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    parser.add_argument(
        "--task-id",
        type=int,
        nargs="+",
        metavar="ID",
        help="export only these task ids (must belong to the project); "
        "omit to export every task in the project",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # 1. Validate the format against the server's list.
        # Low-level API: there is no high-level proxy for the format list yet.
        formats, _ = client.api_client.server_api.retrieve_annotation_formats()
        names = [f.name for f in formats.exporters]
        if args.export_format not in names:
            sys.exit(
                f"Unknown export format {args.export_format!r}. Choose one of: {', '.join(names)}"
            )

        # 2. Resolve which tasks to export.
        project = client.projects.retrieve(args.project_id)
        tasks_by_id = {task.id: task for task in project.get_tasks()}
        if args.task_id:
            missing = [str(tid) for tid in args.task_id if tid not in tasks_by_id]
            if missing:
                sys.exit(f"Task id(s) {', '.join(missing)} not found in project {project.id}")
            tasks = [tasks_by_id[tid] for tid in args.task_id]
        else:
            tasks = list(tasks_by_id.values())
        if not tasks:
            sys.exit(f"Project {project.id} has no tasks to export")

        # 3. Export each task individually: a local zip AND straight to the cloud storage.
        for task in tasks:
            local_path = Path(f"task_{task.id}_dataset.zip")
            task.export_dataset(
                args.export_format, local_path, include_images=False, location=Location.LOCAL
            )
            print(f"Exported {local_path.resolve()}")

            remote_name = f"task_{task.id}_dataset.zip"
            task.export_dataset(
                args.export_format,
                remote_name,
                include_images=False,
                location=Location.CLOUD_STORAGE,
                cloud_storage_id=args.cloud_storage_id,
            )
            print(f"Exported {remote_name} to cloud storage {args.cloud_storage_id}")

        print(f"Exported {len(tasks)} task dataset(s) from project {project.id}")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
Project.download_backup(..., lightweight=True) Produce a smaller backup that omits media.
client.projects.create_from_dataset(...) Create a project directly from a dataset archive.
Project.import_dataset(format_name, path) Import annotations/data into an existing project - the import counterpart of export_dataset.
Project.get_annotations() Fetch the project’s labeled data.

Notes:

  • list() returns the whole collection; pagination is handled for you.
  • A project backup captures tasks, jobs, users, and settings in a single zip - but no raw media beyond what export_dataset would include.
  • For a CSV overview of a project’s jobs (no annotation geometry), use job_list.py --project-id <id> --csv. For an actual dataset export, use project_export_dataset.py.
  • include_images=False exports annotations only and is much smaller.
  • Full recipes: project_create_and_list.py, project_add_labels.py, project_backup.py, project_restore.py, project_export_dataset.py.

3 - Task recipes

Create one task or a batch of tasks from a bucket; inspect and export existing tasks

Three recipes cover the task lifecycle: task_create_from_cloud.py creates one task from object keys already in a registered bucket, tasks_bulk_from_cloud.py creates a whole batch of tasks in a project from that same bucket, and task_inspect_and_export.py inspects an existing task, exports its dataset locally, and reports analytics from its event log.

Create a task from cloud object keys

Creates a task from images that already live in a registered bucket.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--cloud-storage-id yes Registered cloud storage id (see cloud_storage_register.py)
--cloud-keys yes Object keys in the bucket, space-separated
--name no Task name (default 'Task from cloud storage')
--labels no Label names, space-separated (default object)
--cleanup no Delete the created task at the end
python task_create_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --cloud-keys 'images/0001.jpg' 'images/0002.jpg' \
    --labels car person

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Create an annotation task from images that already live in a registered
cloud storage.

Steps:
  1. Create a task whose data is a list of object keys in the bucket.
  2. Print the result.
  3. Optionally delete it (--cleanup).

Register a bucket first with cloud_storage_register.py to get the storage id.

Usage (run ``python task_create_from_cloud.py --help`` for the full list of options):
  python task_create_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --cloud-keys 'images/0001.jpg' 'images/0002.jpg' \\
      --labels car person
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        required=True,
        help="a registered cloud storage id (see cloud_storage_register.py)",
    )
    parser.add_argument(
        "--cloud-keys",
        nargs="+",
        required=True,
        help="object keys in the bucket, e.g. 'images/0001.jpg' 'images/0002.jpg'",
    )
    parser.add_argument(
        "--name",
        default="Task from cloud storage",
        help="task name (default: '%(default)s')",
    )
    parser.add_argument(
        "--labels", nargs="+", default=["object"], help="label names (default: %(default)s)"
    )
    parser.add_argument("--cleanup", action="store_true", help="delete the created task at the end")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # ResourceType.SHARE + cloud_storage_id = read images from the bucket
        task = client.tasks.create_from_data(
            spec=models.TaskWriteRequest(
                name=args.name,
                labels=[models.PatchedLabelRequest(name=name) for name in args.labels],
            ),
            resource_type=ResourceType.SHARE,
            resources=args.cloud_keys,
            data_params={"cloud_storage_id": args.cloud_storage_id},
        )
        print(f"Created task {task.id} with {task.size} frames: {args.host}/tasks/{task.id}")

        if args.cleanup:
            task.remove()
            print(f"Deleted task {task.id}")
        else:
            print("Keeping the task; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Bulk-create tasks in a project from a bucket

Creates several tasks in one call, all inside the same project, each reading its data from a registered cloud storage. Two ways to spell a task’s data, repeatable and mixable: --task KEY[,KEY,...] lists explicit object keys (a single key makes a video/single-image task; multiple keys make an image task whose frames are those keys in order), and --task-pattern PATTERN makes one task from every bucket file matching a fnmatch wildcard (e.g. 'batch_a/*.jpg'), resolved from the bucket’s manifest instead of listing every key by hand. Because every task belongs to the project, they share its label schema — no --labels here.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--cloud-storage-id yes Registered cloud storage id (see cloud_storage_register.py)
--project-id yes Project the tasks are created in; supplies the labels
--task KEY[,KEY,...] one of --task / --task-pattern One --task per task; repeat the flag for more
--task-pattern PATTERN one of --task / --task-pattern One task per wildcard, matched via the bucket’s manifest; repeat for more
--manifest no Manifest object key used to resolve --task-pattern (default 'manifest.jsonl')
--name-prefix no Task-name prefix; each task is named <prefix> N (default 'Bulk task')
--cleanup no Delete every created task at the end
# three video tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --project-id 42 \
    --task 'videos/clip_01.mp4' --task 'videos/clip_02.mp4' --task 'videos/clip_03.mp4'

# two image-batch tasks in project 42
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --project-id 42 \
    --task 'batch_a/img_1.jpg,batch_a/img_2.jpg' \
    --task 'batch_b/img_1.jpg,batch_b/img_2.jpg'

# the same two batches, without listing every key: one task per wildcard match
python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \
    --cloud-storage-id 7 --project-id 42 --manifest manifest.jsonl \
    --task-pattern 'batch_a/*.jpg' --task-pattern 'batch_b/*.jpg'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Bulk-create tasks inside a project, each task's data read from a registered
cloud storage.

Two ways to spell a task's data, repeatable and mixable:
  --task KEY[,KEY,...]    explicit object keys, in order:
                             * a single key -> a video task (or single-image task);
                             * several keys -> an image task, in the given order.
  --task-pattern PATTERN  every bucket file matching a fnmatch wildcard (e.g.
                           'batch_a/*.jpg'), resolved from the bucket's
                           manifest instead of being listed one by one.

All tasks land in the same project, so they share its label schema.

Steps:
  1. For each --task, create a task in --project-id from its explicit keys.
  2. For each --task-pattern, create a task in --project-id from every bucket
     file the wildcard matches, resolved via the bucket's manifest.
  3. Print the created ids and a summary count.
  4. Optionally delete every created task (--cleanup).

Register a bucket first with cloud_storage_register.py to get the storage id.
A --task-pattern also needs a manifest file already generated for the bucket -
see "How to generate manifest file" in the CVAT docs on attaching cloud storage.

Usage (run ``python tasks_bulk_from_cloud.py --help`` for the full list of options):
  # three video tasks in project 42
  python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --project-id 42 \\
      --task 'videos/clip_01.mp4' --task 'videos/clip_02.mp4' --task 'videos/clip_03.mp4'

  # two image-batch tasks in project 42
  python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --project-id 42 \\
      --task 'batch_a/img_1.jpg,batch_a/img_2.jpg' \\
      --task 'batch_b/img_1.jpg,batch_b/img_2.jpg'

  # the same two batches, without listing every key: one task per wildcard match
  python tasks_bulk_from_cloud.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --cloud-storage-id 7 --project-id 42 --manifest manifest.jsonl \\
      --task-pattern 'batch_a/*.jpg' --task-pattern 'batch_b/*.jpg'
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.tasks import ResourceType


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--cloud-storage-id",
        type=int,
        required=True,
        help="a registered cloud storage id (see cloud_storage_register.py)",
    )
    parser.add_argument(
        "--project-id",
        type=int,
        required=True,
        help="tasks are created in this project and inherit its labels",
    )
    parser.add_argument(
        "--task",
        dest="tasks",
        action="append",
        default=[],
        metavar="KEY[,KEY,...]",
        help="comma-separated object keys for one task; repeat for more tasks",
    )
    parser.add_argument(
        "--task-pattern",
        dest="task_patterns",
        action="append",
        default=[],
        metavar="PATTERN",
        help="one task from every bucket file matching this fnmatch wildcard "
        "(e.g. 'batch_a/*.jpg'); repeat for more tasks. Needs --manifest. "
        "(default: '%(default)s')",
    )
    parser.add_argument(
        "--manifest",
        default="manifest.jsonl",
        help="manifest object key in the bucket, used to resolve --task-pattern "
        "(default: '%(default)s')",
    )
    parser.add_argument(
        "--name-prefix",
        default="Bulk task",
        help="task name prefix; each task is named '<prefix> N' (default: '%(default)s')",
    )
    parser.add_argument(
        "--cleanup", action="store_true", help="delete every created task at the end"
    )
    args = parser.parse_args()
    if not args.tasks and not args.task_patterns:
        parser.error("at least one --task or --task-pattern is required")
    return args


def main() -> None:
    args = parse_args()
    task_key_groups = [
        [key.strip() for key in spec.split(",") if key.strip()] for spec in args.tasks
    ]
    if any(not group for group in task_key_groups):
        raise SystemExit("each --task must contain at least one non-empty key")

    with make_client(args.host, access_token=args.token) as client:
        created = []
        for keys in task_key_groups:
            # Tasks in a project inherit the project's labels — do NOT pass labels.
            # ResourceType.SHARE + cloud_storage_id reads the objects from the bucket.
            task = client.tasks.create_from_data(
                spec=models.TaskWriteRequest(
                    name=f"{args.name_prefix} {len(created) + 1}", project_id=args.project_id
                ),
                resource_type=ResourceType.SHARE,
                resources=keys,
                data_params={"cloud_storage_id": args.cloud_storage_id},
            )
            created.append(task)
            print(f"Created task {task.id} ({task.size} frames): {args.host}/tasks/{task.id}")

        for pattern in args.task_patterns:
            # A wildcard task needs the bucket's manifest as its only resource;
            # the server expands filename_pattern against it (fnmatch syntax).
            # use_cache=True is required to serve data straight from the bucket.
            task = client.tasks.create_from_data(
                spec=models.TaskWriteRequest(
                    name=f"{args.name_prefix} {len(created) + 1}", project_id=args.project_id
                ),
                resource_type=ResourceType.SHARE,
                resources=[args.manifest],
                data_params={
                    "cloud_storage_id": args.cloud_storage_id,
                    "use_cache": True,
                    "filename_pattern": pattern,
                },
            )
            created.append(task)
            print(
                f"Created task {task.id} ({task.size} frames) from pattern {pattern!r}: "
                f"{args.host}/tasks/{task.id}"
            )

        print(f"Created {len(created)} tasks in project {args.project_id}")

        if args.cleanup:
            for task in created:
                task.remove()
            print(f"Deleted {len(created)} tasks")
        else:
            print("Keeping the tasks; pass --cleanup to delete them")


if __name__ == "__main__":
    main()

Inspect a task and export its dataset

Prints a summary of an existing task (labels, jobs, frames), exports its dataset to a local zip, then exports the task’s event log and reports two analytics computed from it: how many people are currently assigned to a job, and how many jobs were rejected in review and sent back for rework.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to inspect and export
--export-format no Exporter name (default 'COCO 1.0')
python task_inspect_and_export.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --export-format 'COCO 1.0'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Inspect an existing task (labels, jobs, frames), export its dataset to a
local zip, and export its event log to report quick analytics.

Steps:
  1. Retrieve the task and print a summary: labels, jobs (stage/state), frames.
  2. Fetch the server's export format list and validate --export-format.
  3. Export the dataset to task_<id>_dataset.zip in the current directory.
  4. Export the task's event log to task_<id>_events.csv and report two
     analytics: how many people are currently assigned to a job, and how
     many jobs were rejected in review and sent back for rework - the second
     one needs the log, since a job's current state doesn't show its history.

Usage (run ``python task_inspect_and_export.py --help`` for the full list of options):
  python task_inspect_and_export.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --export-format 'COCO 1.0'
"""

import argparse
import csv
import sys
from pathlib import Path

from cvat_sdk import make_client
from cvat_sdk.core.downloading import Downloader
from cvat_sdk.core.proxies.types import Location


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    parser.add_argument(
        "--export-format",
        default="COCO 1.0",
        help="exporter name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    return parser.parse_args()


def count_reworks(events_path: Path) -> int:
    """Count how many times a job in the log was rejected in review, i.e. sent
    back to the annotator for rework. A job's current state only shows where
    it stands now, not how many times it got there, so this needs the log.
    """
    with events_path.open(newline="") as f:
        return sum(
            1
            for row in csv.DictReader(f)
            if row["scope"] == "update:job"
            and row["obj_name"] == "state"
            and row["obj_val"] == "rejected"
        )


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        # 1. Inspect
        task = client.tasks.retrieve(args.task_id)
        jobs = task.get_jobs()
        print(f"Task {task.id}: {task.name!r}, {task.size} frames")
        print(f"  labels: {[label.name for label in task.get_labels()]}")
        for job in jobs:
            print(f"  job {job.id}: stage={job.stage}, state={job.state}")

        # 2. Validate the export format against the server's list.
        # Low-level API: there is no high-level proxy for the format list yet.
        formats, _ = client.api_client.server_api.retrieve_annotation_formats()
        names = [f.name for f in formats.exporters]
        if args.export_format not in names:
            sys.exit(
                f"Unknown export format {args.export_format!r}. Choose one of: {', '.join(names)}"
            )

        # 3. Export the dataset to a local zip
        local_path = Path(f"task_{task.id}_dataset.zip")
        task.export_dataset(
            args.export_format, local_path, include_images=False, location=Location.LOCAL
        )
        print(f"Exported {local_path.resolve()}")

        # 4. Export the task's event log and report quick analytics.
        events_path = Path(f"task_{task.id}_events.csv")
        Downloader(client).prepare_and_download_file_from_endpoint(
            client.api_client.events_api.create_export_endpoint,
            events_path,
            query_params={"task_id": task.id},
        )
        print(f"Exported {events_path.resolve()}")

        assigned = {job.assignee.id for job in jobs if job.assignee}
        print(f"  {len(assigned)} people currently assigned, {count_reworks(events_path)} reworks")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
client.tasks.create_from_data(..., resource_type=ResourceType.LOCAL | SHARE | REMOTE) Where resources come from: LOCAL (upload local files), SHARE (keys in a cloud storage / mounted share), REMOTE (URLs). Defaults to LOCAL.
client.tasks.create_from_data(..., data_params={...}) Extra data options as a dict, e.g. image_quality (1-100), sorting_method ("lexicographical"/"natural"/"predefined"/"random"), cloud_storage_id (int).
client.tasks.create_from_data(..., annotation_path="path.zip", annotation_format="CVAT XML 1.1") Upload an initial annotations file at creation. annotation_path is a str file path; annotation_format is a str, default "CVAT XML 1.1".
client.tasks.create_from_data(..., status_check_period=<int seconds>, pbar=ProgressReporter()) status_check_period (int, seconds) is the upload status poll interval (defaults to Config.status_check_period); pbar is a cvat_sdk.core.progress.ProgressReporter for upload progress.
client.tasks.list(..., search=, sort=) Free-text search and server-side ordering (sort), in addition to filter.
client.tasks.create_from_backup(path) Recreate a task from a task backup archive.
Task.import_annotations(format_name, path) Load annotations into an existing task - the import counterpart of export_dataset.
Task.get_frame(frame_id: int, *, quality="original" | "compressed") Return a single frame as a file-like object (io.RawIOBase) of image bytes. quality is an optional keyword argument ("original" or "compressed"); if omitted, the server default is used.
Task.download_frames(frame_ids: Sequence[int], outdir=".", quality="original", image_extension=None, filename_pattern="frame_{frame_id:06d}{frame_ext}") Save the given frames to disk under outdir. image_extension (e.g. "png") overrides the auto-detected extension; quality is "original" or "compressed".
Task.get_meta() / Task.get_frames_info() Read frame count, chunk layout, and per-frame metadata.
Task.export_dataset(..., pbar=ProgressReporter()) Report local-download progress (a cvat_sdk.core.progress.ProgressReporter).
Task.export_dataset(..., status_check_period=<int seconds>) Poll interval (int, seconds) between server status checks; defaults to Config.status_check_period.
Task.export_dataset(filename=<directory>) Pass a directory as filename for a local export and the server-generated file name is used.
Task.export_dataset(..., location=Location.CLOUD_STORAGE, cloud_storage_id=<int>) Export straight to a registered cloud storage instead of downloading locally.
client.api_client.events_api.create_export(project_id=, job_id=, user_id=, _from=, to=) Scope or time-bound the event-log export beyond a single task.

Notes:

  • To add a task to a project, pass project_id in TaskWriteRequest and do not pass labels — the task inherits the project’s label schema.
  • Both cloud recipes use ResourceType.SHARE, so the images are read from the bucket rather than uploaded from your machine.
  • include_images=False exports annotations only and is much smaller.
  • Pass a valid format_name from the server’s exporter list, e.g. "COCO 1.0" or "CVAT for images 1.1". An unknown format name is rejected by the recipe.
  • Full recipes: task_create_from_cloud.py, tasks_bulk_from_cloud.py, task_inspect_and_export.py.

4 - Job recipes

List a task’s or project’s jobs, round-robin unassigned jobs, batch-advance completed jobs

Three recipes: job_list.py lists a task’s or project’s jobs with optional stage/state filters and an optional CSV report, job_assign.py round-robins unassigned jobs across a resolved pool of users and writes a CSV report, and job_workflow.py batch-advances every completed job at a given stage to the next stage.

List a task’s or project’s jobs

Queries the jobs of a task or a project (pick one with --task-id or --project-id) with optional server-side --stage / --state filters, ordered by most recently updated. Pass --csv to also write report.csv (project_id, project_name, task_id, task_name, job_id, stage, state, assignee, frames) into the current directory.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id one of --task-id / --project-id Id of the task whose jobs to list
--project-id one of --task-id / --project-id Id of the project whose jobs to list
--stage no Only jobs at this stage, e.g. annotation
--state no Only jobs in this state, e.g. new
--csv no Also write report.csv into the current directory
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --stage annotation --state new
python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --csv

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""List the jobs of an existing task or project with their stage, state, and
assignee, optionally as a CSV report.

Steps:
  1. Query jobs scoped to --task-id or --project-id, most recently updated
     first. --stage / --state filter server-side, so large tasks/projects
     stay cheap. The same endpoint also accepts free-text search, e.g.
     search='alice'.
  2. Print one row per job.
  3. If --csv is passed, also write report.csv into the current directory
     (project_id, project_name, task_id, task_name, job_id, stage, state,
     assignee, frames).

Usage (run ``python job_list.py --help`` for the full list of options):
  python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42
  python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --stage annotation --state new
  python job_list.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --csv
"""

import argparse
import csv
from collections.abc import Iterable
from pathlib import Path

from cvat_sdk import make_client
from cvat_sdk.core.filters import F, all_
from cvat_sdk.core.proxies.jobs import Job


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    scope = parser.add_mutually_exclusive_group(required=True)
    scope.add_argument("--task-id", type=int, help="id of an existing task, e.g. 42")
    scope.add_argument("--project-id", type=int, help="id of an existing project, e.g. 7")
    parser.add_argument("--stage", help="only jobs at this stage, e.g. 'annotation'")
    parser.add_argument("--state", help="only jobs in this state, e.g. 'new'")
    parser.add_argument(
        "--csv", action="store_true", help="also write report.csv into the current directory"
    )
    return parser.parse_args()


def write_report(jobs: Iterable[Job], path: Path) -> None:
    with path.open("w", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(
            [
                "project_id",
                "project_name",
                "task_id",
                "task_name",
                "job_id",
                "stage",
                "state",
                "assignee",
                "frames",
            ]
        )
        for job in jobs:
            assignee = job.assignee.username if job.assignee else ""
            writer.writerow(
                [
                    job.project_id or "",
                    job.project_name or "",
                    job.task_id,
                    job.task_name,
                    job.id,
                    job.stage,
                    job.state,
                    assignee,
                    job.frame_count,
                ]
            )


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        if args.task_id is not None:
            conditions = [F.task_id == args.task_id]
            scope_label = f"Task {args.task_id}"
        else:
            conditions = [F.project_id == args.project_id]
            scope_label = f"Project {args.project_id}"
        if args.stage:
            conditions.append(F.stage == args.stage)
        if args.state:
            conditions.append(F.state == args.state)

        jobs = client.jobs.list(filter=all_(*conditions), sort="-updated_date")
        print(f"{scope_label}: {len(jobs)} matching jobs")
        for job in jobs:
            assignee = job.assignee.username if job.assignee else "-"
            print(f"  job {job.id}: stage={job.stage}, state={job.state}, assignee={assignee}")

        if args.csv:
            report_path = Path("report.csv")
            write_report(jobs, report_path)
            print(f"Wrote {report_path.resolve()}")


if __name__ == "__main__":
    main()

Round-robin assign a task’s jobs

Distributes the unassigned jobs of a task across a resolved user pool and writes assignments.csv (job_id, previous_assignee, new_assignee, new_assignee_id). The pool is resolved by looking up usernames exactly with --assignees, by searching an organization’s members with --search, or self-assigns if neither is passed.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task
--org SLUG no Organization slug to scope the user and job queries
--org-id ID no Organization id, as an alternative to --org
--assignees USERNAME [...] no Usernames to round-robin (exact match)
--search QUERY no Search the organization’s members; every match becomes an assignee

--assignees and --search are mutually exclusive, and so are --org and --org-id. Omit both --assignees and --search to self-assign.

--search requires an organization, so pass it together with --org or --org-id. Search matches the username, first_name, and last_name fields, which is only meaningful scoped to a team.

# self-assign every unassigned job
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42
# round-robin across an explicit pool
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --assignees alice bob
# pool = every organization member matching the search
python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --org 'annotators' --search 'annotator-team'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Round-robin the unassigned jobs of a task across a set of annotators and
write a CSV report of the assignments (job_id, previous_assignee, new_assignee).

The user API supports server-side search within an organization, so you rarely
need to know user ids — pass usernames, or an organization and search query,
and let the recipe resolve them.

Steps:
  1. Resolve the assignee pool:
       --assignees USERNAME [USERNAME ...] : look up each username exactly.
       --search QUERY --org SLUG           : search organization members,
                                             print the matches, use them all.
       --search QUERY --org-id ID          : same, using the organization id.
       neither                             : assign to me (the authenticated user).
  2. Filter the task's unassigned jobs.
  3. Round-robin the jobs across the resolved users.
  4. Write assignments.csv into the current directory.

Usage (run ``python job_assign.py --help`` for the full list of options):
  python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42                              # self-assign
  python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --assignees alice bob
  python job_assign.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --org 'annotators' --search 'annotator-team'
                                                  # pool = matches in the organization
"""

import argparse
import csv
import sys
from pathlib import Path

from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F, all_, not_
from cvat_sdk.core.proxies.users import User


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    organization = parser.add_mutually_exclusive_group()
    organization.add_argument(
        "--org", metavar="SLUG", help="organization slug to scope user and job queries"
    )
    organization.add_argument(
        "--org-id", type=int, metavar="ID", help="organization id to scope user and job queries"
    )
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "--assignees",
        nargs="+",
        metavar="USERNAME",
        help="usernames to round-robin across (looked up exactly on the server)",
    )
    group.add_argument(
        "--search",
        metavar="QUERY",
        help="server-side search within --org/--org-id; every matching member becomes an assignee",
    )
    args = parser.parse_args()
    if args.search and args.org is None and args.org_id is None:
        parser.error("--search requires --org or --org-id")
    return args


def organization_filters(args: argparse.Namespace) -> dict[str, str | int]:
    if args.org is not None:
        return {"org": args.org}
    if args.org_id is not None:
        return {"org_id": args.org_id}
    return {}


def resolve_pool(client, args: argparse.Namespace) -> list[User]:
    """Resolve --assignees / --search / nothing to a list of User objects."""
    org_filters = organization_filters(args)
    if args.search:
        matches = client.users.list(search=args.search, **org_filters)
        if not matches:
            sys.exit(f"No users matched search {args.search!r}")
        print(f"Users matching {args.search!r}:")
        for user in matches:
            print(f"  {user.id}\t{user.username}")
        return matches

    if args.assignees:
        pool: list[User] = []
        for username in args.assignees:
            found = client.users.list(filter=F.username == username, **org_filters)
            if not found:
                sys.exit(f"User {username!r} not found")
            pool.append(found[0])
        return pool

    me = client.users.retrieve_current_user()
    print(f"No --assignees / --search; self-assigning as {me.username} (id={me.id})")
    return [me]


def main() -> None:
    args = parse_args()
    report_path = Path("assignments.csv")
    with make_client(args.host, access_token=args.token) as client:
        pool = resolve_pool(client, args)

        unassigned = client.jobs.list(
            filter=all_(F.task_id == args.task_id, not_(F.assignee.is_set())),
            **organization_filters(args),
        )
        print(f"Task {args.task_id}: {len(unassigned)} unassigned jobs to distribute")

        with report_path.open("w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["job_id", "previous_assignee", "new_assignee", "new_assignee_id"])
            for i, job in enumerate(unassigned):
                user = pool[i % len(pool)]
                previous = job.assignee.username if job.assignee else ""
                job.update(models.PatchedJobWriteRequest(assignee=user.id))
                writer.writerow([job.id, previous, user.username, user.id])
                print(f"Assigned job {job.id} -> {user.username} (id={user.id})")

        print(f"Wrote {report_path.resolve()}")


if __name__ == "__main__":
    main()

Batch-advance completed jobs

Finds every job whose state is completed at --from-stage and moves each one to the next stage (annotation → validation → acceptance). Optionally restrict the sweep to a single task.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--from-stage yes Advance completed jobs at this stage (annotation or validation)
--task-id no Restrict the sweep to a single task
# send everything annotators finished into review
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \
    --from-stage annotation
# accept everything that passed review, scoped to one task
python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \
    --from-stage validation --task-id 42

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Batch-advance completed jobs to the next workflow stage

Find every job whose state is 'completed' at --from-stage, move each one to
the next stage, and print the list of modified jobs. Optionally restrict the
sweep to a single task with --task-id.

Steps:
  1. Query jobs matching (stage == --from-stage, state == 'completed').
  2. Update each job's stage to the next one in the workflow.
  3. Print the modified job ids.

Usage (run ``python job_workflow.py --help`` for the full list of options):
  # Send everything annotators finished into review:
  python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --from-stage annotation
  # Accept everything that passed review, scoped to one task:
  python job_workflow.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --from-stage validation --task-id 42
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.filters import F, all_

NEXT_STAGE = {"annotation": "validation", "validation": "acceptance"}


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--from-stage",
        required=True,
        choices=sorted(NEXT_STAGE),
        help="advance completed jobs currently at this stage",
    )
    parser.add_argument(
        "--task-id",
        type=int,
        help="restrict the sweep to a single task (default: every task you can see)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    to_stage = NEXT_STAGE[args.from_stage]

    with make_client(args.host, access_token=args.token) as client:
        conditions = [F.stage == args.from_stage, F.state == "completed"]
        if args.task_id is not None:
            conditions.append(F.task_id == args.task_id)

        jobs = client.jobs.list(filter=all_(*conditions))
        print(f"Found {len(jobs)} completed jobs at stage {args.from_stage!r}")

        for job in jobs:
            job.update(models.PatchedJobWriteRequest(stage=to_stage))
            print(f"  job {job.id}: {args.from_stage} -> {to_stage}")

        print(f"Moved {len(jobs)} jobs to stage {to_stage!r}")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
Job.update(models.PatchedJobWriteRequest(stage=...)) Change a job’s stage (retrieve the job, then update). Must be one of: annotation, validation, acceptance.
Job.update(models.PatchedJobWriteRequest(state=...)) Change a job’s state, must be one of these values: new, in progress, rejected, completed.
Job.import_annotations(..., import_mode="replace" | "append") "replace" overwrites the job’s existing annotations (default); "append" merges the imported ones in.
Job.import_annotations(..., conv_mask_to_poly=True | False) Convert imported mask annotations to polygons (bool, server default True).
Job.import_annotations(..., pbar=ProgressReporter()) Report upload progress (a cvat_sdk.core.progress.ProgressReporter).
Job.get_issues() Fetch the review issues raised on a job.
Job.export_dataset(format_name, path) Export a single job’s dataset - the export counterpart of import_annotations.
Job.get_frame(frame_id: int, *, quality="original" | "compressed") Return a single frame as a file-like object (io.RawIOBase) of image bytes. quality is an optional keyword argument ("original" or "compressed"); if omitted, the server default is used.
Job.download_frames(frame_ids: Sequence[int], outdir=".", quality="original", image_extension=None, filename_pattern="frame_{frame_id:06d}{frame_ext}") Save the given frames to disk under outdir. image_extension (e.g. "png") overrides the auto-detected extension; quality is "original" or "compressed".
Job.get_meta() / Job.get_labels() Read a job’s frame metadata and label schema.

Notes:

  • stage is one of annotation, validation, acceptance; state is one of new, in progress, rejected, completed.
  • Jobs are created automatically with their task (controlled by segment_size at task creation) — you can update and assign them, but not create a job on its own.
  • CVAT has no built-in auto-assignment, so job_assign.py is the scripted pattern.
  • Full recipes: job_list.py, job_assign.py, job_workflow.py.

5 - Annotation recipes

Import annotations into a task, edit them in bulk, and aggregate annotation statistics over a project

Three recipes: task_import_annotations.py loads an annotation file into an existing task, task_edit_annotations.py reads a task’s annotations, applies a bulk edit, and writes it back, and project_annotation_stats.py walks a project’s tasks and aggregates object counts per label and type into a CSV report.

Import annotations into a task

Uploads a local annotations file (e.g., predictions of a model, or work exported from another server) into an existing task, and shows the object counts before and after, so you can see what the import added. The import format is validated against the server’s importer list.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to import into
--annotations-file yes File to import, e.g. 'annotations.zip'
--import-format no Importer name (default 'COCO 1.0')
python task_import_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --annotations-file 'annotations.zip' --import-format 'COCO 1.0'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Import annotations from a local file into an existing task, e.g. to load
predictions of a model or work made on another server.

Steps:
  1. Retrieve the task and count the objects it already has.
  2. Fetch the server's import format list and validate --import-format.
  3. Upload the annotations file and wait for the server to process it.
  4. Count the objects again to show what the import added.

Usage (run ``python task_import_annotations.py --help`` for the full list of options):
  python task_import_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --annotations-file 'annotations.zip' --import-format 'COCO 1.0'
"""

import argparse
import sys
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    parser.add_argument(
        "--annotations-file",
        type=Path,
        required=True,
        help="file to import, e.g. 'annotations.zip'",
    )
    parser.add_argument(
        "--import-format",
        default="COCO 1.0",
        help="importer name, e.g. 'COCO 1.0' (default: '%(default)s')",
    )
    return parser.parse_args()


def count_objects(task) -> int:
    """All annotation objects of a task: tags, shapes, and tracks."""
    annotations = task.get_annotations()
    return len(annotations.tags) + len(annotations.shapes) + len(annotations.tracks)


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        task = client.tasks.retrieve(args.task_id)
        print(f"Task {task.id}: {count_objects(task)} objects before import")

        formats, _ = client.api_client.server_api.retrieve_annotation_formats()
        names = [f.name for f in formats.importers]
        if args.import_format not in names:
            sys.exit(
                f"Unknown import format {args.import_format!r}. Choose one of: {', '.join(names)}"
            )

        task.import_annotations(args.import_format, args.annotations_file)
        print(f"Imported {args.annotations_file} as {args.import_format!r}")

        print(f"Task {task.id}: {count_objects(task)} objects after import")


if __name__ == "__main__":
    main()

Read, edit, and write back annotations

Reads all of a task’s annotations (tags, shapes, and tracks), applies one bulk edit — move every object from one label to another (--relabel FROM TO) or delete every object with a given label (--delete-label NAME) — and writes the edit back with a partial update, so the untouched objects are not re-uploaded. Prints the per-label object counts before and after.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--task-id yes Id of the task to edit
--relabel FROM TO one of --relabel / --delete-label Move all objects from label FROM to label TO
--delete-label NAME one of --relabel / --delete-label Delete all objects with this label
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --relabel 'car' 'vehicle'
python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \
    --task-id 42 --delete-label 'draft'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Read a task's annotations, edit them, and write the edit back: move every
object from one label to another (--relabel) or delete every object with a
given label (--delete-label).

Steps:
  1. Retrieve the task and map its label names to ids.
  2. Read all annotations (tags, shapes, and tracks) and count objects per label.
  3. Write the edit back with a partial annotation update, so the objects that
     are not affected by it are not re-uploaded.
  4. Re-read the annotations and print the per-label object counts diff.

Usage (run ``python task_edit_annotations.py --help`` for the full list of options):
  python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --relabel 'car' 'vehicle'
  python task_edit_annotations.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --task-id 42 --delete-label 'draft'
"""

import argparse
import sys
from collections import Counter

from cvat_sdk import make_client, models
from cvat_sdk.core.proxies.annotations import AnnotationUpdateAction


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--task-id", type=int, required=True, help="id of an existing task, e.g. 42"
    )
    action = parser.add_mutually_exclusive_group(required=True)
    action.add_argument(
        "--relabel",
        nargs=2,
        metavar=("FROM", "TO"),
        help="move all objects from label FROM to label TO",
    )
    action.add_argument("--delete-label", metavar="NAME", help="delete all objects with this label")
    return parser.parse_args()


def label_counts(annotations, label_names: dict[int, str]) -> Counter:
    """Objects per label name, over tags, shapes, and tracks alike."""
    return Counter(
        label_names[obj.label_id]
        for obj in [*annotations.tags, *annotations.shapes, *annotations.tracks]
    )


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        task = client.tasks.retrieve(args.task_id)
        label_ids = {label.name: label.id for label in task.get_labels()}
        label_names = {id: name for name, id in label_ids.items()}

        source = args.relabel[0] if args.relabel else args.delete_label
        affected = set(args.relabel) if args.relabel else {source}
        for name in affected:
            if name not in label_ids:
                sys.exit(f"Label {name!r} not found in task {task.id}")

        annotations = task.get_annotations()
        before = label_counts(annotations, label_names)

        tags = [tag for tag in annotations.tags if tag.label_id == label_ids[source]]
        shapes = [shape for shape in annotations.shapes if shape.label_id == label_ids[source]]
        tracks = [track for track in annotations.tracks if track.label_id == label_ids[source]]
        matched = len(tags) + len(shapes) + len(tracks)

        if args.relabel:
            target_id = label_ids[args.relabel[1]]
            task.update_annotations(
                models.PatchedLabeledDataRequest(
                    tags=[
                        models.LabeledImageRequest(**{**tag.to_dict(), "label_id": target_id})
                        for tag in tags
                    ],
                    shapes=[
                        models.LabeledShapeRequest(**{**shape.to_dict(), "label_id": target_id})
                        for shape in shapes
                    ],
                    tracks=[
                        models.LabeledTrackRequest(**{**track.to_dict(), "label_id": target_id})
                        for track in tracks
                    ],
                ),
                action=AnnotationUpdateAction.UPDATE,
            )
            print(f"Moved {matched} objects from {source!r} to {args.relabel[1]!r}")
        else:
            # An empty id list would make remove_annotations() drop *all* the task
            # annotations, so skip the request when the label has no objects.
            if matched:
                task.remove_annotations(ids=[obj.id for obj in [*tags, *shapes, *tracks]])
            print(f"Deleted {matched} objects with label {source!r}")

        after = label_counts(task.get_annotations(), label_names)
        for name in sorted(affected):
            print(f"  {name}: {before[name]} -> {after[name]}")


if __name__ == "__main__":
    main()

Aggregate annotation statistics over a project

Walks every task of a project and counts the annotated objects per label and per type (a shape type such as rectangle or polygon, tag, or track). Prints a per-task breakdown with per-label project totals and writes annotation_stats.csv into the current directory — one row per (task, label, type).

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Id of the project to aggregate
python project_annotation_stats.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Aggregate what was annotated across a project: object counts per label and
per object type for every task, printed and written to a CSV report.

Steps:
  1. Retrieve the project and its label names.
  2. Walk the project's tasks and read each task's annotations.
  3. Count objects per (label, type), where the type is a shape type such as
     'rectangle' or 'polygon', 'tag' for tags, or 'track' for tracks.
  4. Print a per-task breakdown with per-label project totals, and write
     the CSV report to --output.

Usage (run ``python project_annotation_stats.py --help`` for the full list of options):
  python project_annotation_stats.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --project-id 7 --output annotation_stats.csv
"""

import argparse
import csv
from collections import Counter
from pathlib import Path

from cvat_sdk import make_client


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument(
        "--project-id", type=int, required=True, help="id of an existing project, e.g. 7"
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=Path("annotation_stats.csv"),
        help="path to write the CSV report to (default: %(default)s)",
    )
    return parser.parse_args()


def task_counts(task, label_names: dict[int, str]) -> Counter:
    """Objects per (label name, object type) in one task."""
    annotations = task.get_annotations()
    counts = Counter()
    for tag in annotations.tags:
        counts[(label_names[tag.label_id], "tag")] += 1
    for shape in annotations.shapes:
        counts[(label_names[shape.label_id], str(shape.type))] += 1
    for track in annotations.tracks:
        counts[(label_names[track.label_id], "track")] += 1
    return counts


def main() -> None:
    args = parse_args()
    report_path = args.output
    with make_client(args.host, access_token=args.token) as client:
        project = client.projects.retrieve(args.project_id)
        label_names = {label.id: label.name for label in project.get_labels()}
        tasks = project.get_tasks()

        label_totals = Counter()
        total = 0
        with report_path.open("w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["task_id", "task_name", "label", "type", "count"])
            for task in tasks:
                counts = task_counts(task, label_names)
                print(f"Task {task.id} {task.name!r}: {sum(counts.values())} objects")
                for (label, type_), count in sorted(counts.items()):
                    print(f"  {label}/{type_}: {count}")
                    writer.writerow([task.id, task.name, label, type_, count])
                    label_totals[label] += count
                total += sum(counts.values())

        print(f"Project {project.id}: {total} objects across {len(tasks)} tasks")
        for label, count in sorted(label_totals.items()):
            print(f"  {label}: {count}")
        print(f"Wrote {report_path.resolve()}")


if __name__ == "__main__":
    main()

Other SDK options:

SDK method / parameter What it adds
Task.import_annotations(..., import_mode="append") Add the imported objects to the existing annotations instead of replacing them (server support required).
Task.import_annotations(..., conv_mask_to_poly=True) Convert imported masks to polygons on the fly.
Task.import_annotations(..., pbar=ProgressReporter()) Report upload progress (a cvat_sdk.core.progress.ProgressReporter).
Job.import_annotations(format_name, path) The same import scoped to a single job.
Task.set_annotations(LabeledDataRequest(...)) Replace a task’s annotations with the given objects.
Task.update_annotations(PatchedLabeledDataRequest(...), action=AnnotationUpdateAction.CREATE | UPDATE | DELETE) Partial update: create, update, or delete only the objects in the request.
Task.remove_annotations(ids=[...]) Delete specific objects by id — or all of them when ids is omitted.
Project.get_annotations() Read the annotations of every task in a project in one call.

Notes:

6 - Cloud storage recipes

Attach an S3-compatible bucket to CVAT via the low-level cloud storages API

One recipe: cloud_storage_register.py registers an S3-compatible bucket (AWS S3, MinIO, DigitalOcean Spaces, …) as a CVAT cloud storage. It uses the low-level client.api_client.cloudstorages_api because there is no high-level proxy for cloud storages yet.

Attach a bucket to CVAT

Registers a bucket by key/secret, lists all registered storages, retrieves the new one, lists the bucket’s actual content, and renames it — a smoke test that the credentials work.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--bucket yes Bucket name
--access-key yes Bucket access key id
--secret-key yes Bucket secret key
--endpoint-url yes Endpoint URL, e.g. 'https://s3.amazonaws.com'
--page-size no Entries per bucket listing request (default: the server maximum, 500)
--cleanup no Detach the bucket from CVAT at the end (data untouched)
python cloud_storage_register.py --host 'https://app.cvat.ai' --token '<your token>' \
    --bucket 'my-bucket' --access-key '<key>' --secret-key '<secret>' \
    --endpoint-url 'https://s3.amazonaws.com'

The script

# Copyright (C) CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT

"""Attach an S3-compatible bucket to CVAT as a cloud storage, then list,
retrieve, and update it. Any S3-compatible service works (AWS S3, minio, ...)
via the AWS_S3_BUCKET provider and a custom endpoint URL.

There is no high-level proxy for cloud storages yet, so this recipe uses the
low-level API (client.api_client.cloudstorages_api).

Steps:
  1. Attach the bucket with key/secret credentials to CVAT.
  2. List all registered storages.
  3. Retrieve the new one.
  4. List the bucket's content, a page at a time.
  5. Update its display name.
  6. Optionally, detach it from CVAT.

Usage (run ``python cloud_storage_register.py --help`` for the full list of options):
  python cloud_storage_register.py --host 'https://app.cvat.ai' --token '<your token>' \\
      --bucket 'my-bucket' --access-key '<key>' --secret-key '<secret>' \\
      --endpoint-url 'https://s3.amazonaws.com'
"""

import argparse

from cvat_sdk import make_client, models
from cvat_sdk.core.helpers import get_paginated_collection


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--host", required=True, help="CVAT server URL, e.g. 'https://app.cvat.ai'")
    parser.add_argument(
        "--token",
        required=True,
        help="Personal Access Token (CVAT UI: Profile -> Security)",
    )
    parser.add_argument("--bucket", required=True, help="the bucket name, e.g. 'my-bucket'")
    parser.add_argument("--access-key", required=True, help="the bucket's access key id")
    parser.add_argument("--secret-key", required=True, help="the bucket's secret key")
    parser.add_argument(
        "--endpoint-url",
        required=True,
        help="e.g. 'https://s3.amazonaws.com' or 'http://minio:9000'",
    )
    parser.add_argument(
        "--page-size",
        type=int,
        help="entries to fetch per bucket listing request (default: the server's "
        "maximum, 500); a small value makes the pagination loop visible",
    )
    parser.add_argument(
        "--cleanup",
        action="store_true",
        help="detach the storage at the end (data is never touched)",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    with make_client(args.host, access_token=args.token) as client:
        api = client.api_client.cloudstorages_api

        # 1. Register
        storage, _ = api.create(
            models.CloudStorageWriteRequest(
                provider_type="AWS_S3_BUCKET",  # any S3-compatible service
                resource=args.bucket,
                display_name=args.bucket,
                credentials_type="KEY_SECRET_KEY_PAIR",
                key=args.access_key,
                secret_key=args.secret_key,
                specific_attributes=f"endpoint_url={args.endpoint_url}",
            )
        )
        print(f"Registered cloud storage {storage.id} -> {args.bucket}")

        # 2. List — api.list() returns a single page. Pair it with
        # get_paginated_collection to walk every page of any low-level list
        # endpoint (works for tasks_api.list_endpoint, jobs_api.list_endpoint, ...).
        storages = get_paginated_collection(api.list_endpoint)
        print(f"Registered storages: {[cs.id for cs in storages]}")

        # 3. Retrieve — credentials are never returned, only metadata
        fetched, _ = api.retrieve(storage.id)
        print(f"Storage {fetched.id}: {fetched.display_name!r} ({fetched.provider_type})")

        # 4. List the bucket's content, a page at a time via next_token.
        page_params = {"page_size": args.page_size} if args.page_size else {}
        files = []
        pages = 0
        next_token = None
        while True:
            content, _ = api.retrieve_content_v2(
                storage.id,
                **page_params,
                **({"next_token": next_token} if next_token else {}),
            )
            files.extend(content.content)
            pages += 1
            if not content.next:
                break
            next_token = content.next
        print(f"Bucket {args.bucket!r} contains {len(files)} entries in {pages} page(s):")
        for f in files:
            print(f"  {f.type.value:>3} {f.name}")

        # 5. Update the display name (PATCH — only the passed fields change)
        updated, _ = api.partial_update(
            storage.id,
            patched_cloud_storage_write_request=models.PatchedCloudStorageWriteRequest(
                display_name=f"{args.bucket} (updated)"
            ),
        )
        print(f"Renamed storage {updated.id} to {updated.display_name!r}")

        # 6. Opt-in cleanup: detaches the bucket from CVAT, never deletes data
        if args.cleanup:
            api.destroy(storage.id)
            print(f"Deleted cloud storage {storage.id}")
        else:
            print("Keeping the storage; pass --cleanup to delete it")


if __name__ == "__main__":
    main()

Other SDK options:

The recipe uses the low-level client.api_client.cloudstorages_api because there is no high-level proxy for cloud storages yet.

SDK method / parameter What it adds
models.CloudStorageWriteRequest(description=...) Free-text description shown alongside the storage.
models.CloudStorageWriteRequest(manifests=[...]) Attach manifest files so CVAT can index large buckets faster.
CloudStorageWriteRequest(session_token=..., connection_string=..., account_name=...) Alternative credential fields for other providers (e.g. Azure, temporary S3 sessions).
cloudstorages_api.retrieve_status(id=...) Check whether a registered storage is reachable/healthy.
cloudstorages_api.retrieve_actions(id: int) Return the operations the credentials allow on the bucket (e.g. "read" / "read,write") as a string. id is the cloud storage id; the string is the returned data (first tuple element).
cloudstorages_api.retrieve_content_v2(id, prefix=..., manifest_path=..., page_size=...) List the bucket’s actual files/directories. prefix filters to one “directory”; manifest_path lists from a manifest instead of a live bucket scan (faster for large buckets).
cloudstorages_api.retrieve_preview(id: int) Fetch a preview image for the storage. id is the cloud storage id; the image bytes are on the HTTP response (response.data, the second tuple element), not the parsed data.
PatchedCloudStorageWriteRequest(key=..., secret_key=...) Rotate credentials through partial_update (any writable field can be patched).
get_paginated_collection(api.list_endpoint) Walk every page of any low-level *_api.list_endpoint (tasks, jobs, cloud storages, …); returns a flat list.

Notes:

  • The server validates the bucket by connecting to endpoint_url itself, so use an address the server container can reach.
  • Cleanup detaches the bucket from CVAT; the bucket’s contents are never touched.
  • Full recipe: cloud_storage_register.py.

7 - Webhook recipes

Register a webhook for task events and monitor task status changes live with a local receiver

Two recipes: webhook_register.py creates a webhook for task events on a project or an organization, pings it, and summarizes its recorded deliveries; webhook_monitor.py is the receiving side — it runs a local HTTP server, registers a webhook pointing at it, verifies each delivery’s signature, and aggregates task status changes live.

CVAT signs every delivery with the webhook secret: the X-Signature-256 header carries sha256=<HMAC-SHA256 of the request body>. A receiver that recomputes and compares the signature (as webhook_monitor.py does) can be sure the payload came from the server and not from someone who merely knows the URL.

Register a webhook and inspect its deliveries

Creates a webhook scoped to a project (--project-id) or a whole organization (--org), sends a test ping, then lists all recorded deliveries with get_paginated_collection() and prints how many there are per HTTP status. There is no high-level proxy for webhooks yet, so the recipe shows the low-level client.api_client.webhooks_api.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id one of --project-id / --org Watch one project
--org SLUG one of --project-id / --org Watch a whole organization
--target-url yes Where the server delivers the events
--secret yes Secret the server signs the deliveries with
--events no Events to subscribe to (default: create:task update:task delete:task)
--cleanup no Delete the created webhook at the end
python webhook_register.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'
python webhook_register.py --host 'https://app.cvat.ai' --token '<your token>' \
    --org 'annotators' --target-url 'https://ci.example.com/cvat-events' --secret 'w3bh00k'

The script

Monitor task status changes live

Starts a local HTTP server on --port, registers a project webhook targeting --public-url (how the CVAT server reaches this machine — a public IP, a DNS name, or a tunnel), and then, for every delivery: verifies the signature, tallies the event, and for task updates prints and tallies the status change. On Ctrl-C — or after --max-events verified events — it prints the tallies.

Flag Required Meaning
--host yes Server URL
--token yes Personal Access Token
--project-id yes Project whose task events to monitor
--public-url yes URL under which the CVAT server can reach this machine
--port no Local port to listen on (default 8000)
--secret yes Secret the server signs the deliveries with
--max-events no Stop after this many verified events (default: run until Ctrl-C)
--cleanup no Delete the created webhook at the end
python webhook_monitor.py --host 'https://app.cvat.ai' --token '<your token>' \
    --project-id 7 --public-url 'https://my-tunnel.example.com/payload' \
    --port 8000 --secret 'w3bh00k'

The script

Other SDK options:

SDK method / parameter What it adds
webhooks_api.list(project_id=, target_url=, type=, ...) Filter the webhook list server-side.
webhooks_api.retrieve_events() The full list of event names a webhook can subscribe to.
webhooks_api.create_deliveries_redelivery(id, delivery_id) Re-send a failed delivery.
webhooks_api.partial_update(id, patched_webhook_write_request=...) Change a webhook’s target, events, or active state in place.
WebhookWriteRequest(..., is_active=False) Create a webhook disabled, to be enabled later.
WebhookWriteRequest(..., enable_ssl=False) Skip TLS certificate verification for self-signed receivers.

Notes:

  • Webhook payloads carry the event name (e.g. update:task), the serialized resource, the sender, and — for updates — before_update/changes with the old field values.
  • An organization webhook lives in the organization’s scope, so every call about it must be made in that organization’s context (client.organization_context(slug)).
  • The delivery list is paginated like every list endpoint; get_paginated_collection() walks all the pages.
  • Full recipes: webhook_register.py, webhook_monitor.py.