Skip to main content

Craaft Python SDK

The official Python client for Craaft, the kanban board for one person or a small team who wants to see the work, not administer the tool.

Drive the same boards, cards, comments and members you see in the app: typed dataclasses instead of raw dicts, retries that honour Retry-After, and exceptions you can catch by failure type instead of by status code. Every call runs as the user who minted the token, with exactly the permissions they have in the UI.

Install

pip install craaft

Python 3.10 or newer. requests is the only runtime dependency.

Get a token

Open Settings → API keys in Craaft, or go straight to craaft.io/settings/api-keys.

A token looks like cra_... and is shown exactly once, so copy it before you close the dialog. The cra_ prefix is deliberate: it makes an accidental commit scannable by tooling like GitHub Push Protection.

export CRAAFT_API_TOKEN=cra_...

The client reads that variable by default, so nothing in your code has to hold the token.

Quickstart

from datetime import datetime, timedelta, timezone

from craaft import CraaftClient

# Reads CRAAFT_API_TOKEN (and optionally CRAAFT_BASE_URL) from the environment.
with CraaftClient() as client:
    me = client.me.get()
    print(f"Hi {me.name}")

    project = client.projects.create(name="Demo", description="A new board")

    card = client.projects.create_card(
        project.id,
        title="Ship the SDK",
        column="todo",
        position=1.0,
        description="all the bits",
    )

    # Metadata (priority, due_date, size, tags) is set via PATCH after create.
    client.cards.update(
        card.id,
        priority="high",
        size=3,
        due_date=datetime.now(timezone.utc) + timedelta(days=7),
    )

    client.cards.add_comment(card.id, body="lgtm")

    # upcoming() returns lightweight UpcomingCard previews, not full cards.
    for summary in client.cards.upcoming():
        print(summary.title, summary.due_date, summary.project_name)

Examples

The examples/ directory has runnable scripts for the most common patterns. Each one is self-contained and cleans up after itself, so they're safe to run repeatedly:

File What it shows
quickstart.py Sign in, create a card, leave a comment.
card_lifecycle.py Create, set priority and due date via PATCH, comment, move between columns, delete.
error_handling.py Which exceptions to catch and what fields they carry.
retries.py Tuning RetryConfig and reacting to RateLimitError yourself.
searching.py cards.search() (SearchResult) and cards.upcoming() (UpcomingCard).
advanced_client.py Custom session, alternate base URL, user-agent, debug logging.

Set CRAAFT_API_TOKEN (and optionally CRAAFT_BASE_URL) in your environment, then python examples/quickstart.py.

Configuration

from craaft import CraaftClient, RetryConfig

client = CraaftClient(
    api_key="cra_...",                     # or CRAAFT_API_TOKEN env var
    base_url="https://craaft.io/api/v1",   # or CRAAFT_BASE_URL env var (default: prod)
    timeout=30.0,                          # seconds, or (connect, read) tuple
    retry=RetryConfig(max_attempts=5),     # or retry=None to disable
    user_agent="my-app/1.0",
)

Resources

Sub-client Methods
client.me get(), update(name=, email=, username=)
client.projects list(), get(id), create(..., template=), update(id, ...), delete(id), list_templates(), export(id), export_csv(id), list_tags(id), enable_share(id), disable_share(id), list_cards(id), list_archived_cards(id), create_card(id, ...), bulk_create_cards(id, cards), rebalance_cards(id, ids, column=), upload_background(id, file=), download_background(id), delete_background(id), list_milestones(id), add_milestone(id, name=, due_on=), add_column(id, title=), list_members(id), add_member(id, ...), update_member(id, ...), remove_member(id, ...)
client.cards get(id), detail(id), update(id, ...), bulk_update(cards), bulk_move(ids, column=, target_project_id=), delete(id), archive(id), restore(id), move(id, ...), follow(id), unfollow(id), upcoming(), focus(), hygiene(type=), list_events(id), search(q=, limit=20), list_comments(id), add_comment(id, body=), list_checklist(id), add_checklist_item(id, text=)
client.attachments list_for_card(card_id), upload(card_id, file=, filename=, content_type=), download(attachment_id), delete(attachment_id)
client.comments update(id, body=), delete(id)
client.checklist update(id, text=, done=), delete(id)
client.columns update(id, ...), delete(id), archive(id), archive_with_ids(id)
client.milestones update(id, name=, due_on=, achieved=), delete(id)
client.members list(), update_role(user_id, role=), remove(user_id), list_invitations(), create_invitation(...), revoke_invitation(id)
client.webhooks list_for_project(project_id), create(project_id, url=, ...), update(id, ...), delete(id)
client.inbound_email get(project_id), enable(project_id, target_column=), update(project_id, ...), disable(project_id)
client.notifications list(before=, limit=30, unread=, kind=), iter_all(limit=50, unread=, kind=), count(), mark_seen(up_to), mark_read(ids, card_id=), read_all()
client.public board(token), board_background(token), avatar(user_id) - no auth required

search() returns list[SearchResult] and upcoming() returns list[UpcomingCard] - lightweight, endpoint-specific previews (see Models). focus() returns a FocusResponse with due (list[UpcomingCard]), attention, and hygiene buckets. client.version() returns the server's build info and needs no auth, which makes it a cheap liveness probe.

The one endpoint the SDK deliberately does not wrap is GET /projects/{id}/events. That is the realtime SSE stream, not an activity log: it opens a text/event-stream that never completes, so a request/response client would simply hang on it. Per-card history is cards.list_events(id), which is ordinary JSON.

Following and board maintenance

card = client.cards.get(card_id)      # single fetch, no board scan
detail = client.cards.detail(card_id)  # card + comments + events + checklist + attachments, one call
if not card.following:
    client.cards.follow(card_id)      # idempotent, returns None

# Only when repeated midpoint inserts have run out of room between two
# neighbours. Request order becomes positions 1, 2, 3, ...
client.projects.rebalance_cards(project.id, ordered_ids, column="doing")

Board templates

for template in client.projects.list_templates():
    print(template.key, template.name, len(template.columns))

# Seed the new board with the "sprint" template's columns instead of the
# default Kanban three. Omit template= (or pass "") for the default.
project = client.projects.create(name="Q3 Sprint", template="sprint")

An unrecognised template key raises ValidationError (400). The create response doesn't include the board's columns; call client.projects.get() on the new id to read their keys (only the default template uses todo / doing / done).

Archiving cards

client.cards.archive(card.id)              # off the board, still searchable
archived = client.projects.list_archived_cards(project.id)
client.cards.restore(archived[0].id)       # back to its original column + position

# Bulk-archive every live card in a done column.
count = client.columns.archive(done_column.id)

# Same, but also returning the archived card ids so you can offer an undo.
result = client.columns.archive_with_ids(done_column.id)
print(result.archived, result.ids)

Archiving a card that is already archived, or restoring one that isn't, raises NotFoundError. columns.archive() / archive_with_ids() only take effect on a column flagged is_done - any other column answers archived=0 rather than an error.

Board backgrounds

project = client.projects.upload_background(project.id, file="hero.png")
raw = client.projects.download_background(project.id)
client.projects.delete_background(project.id)

Board admins only, max 10 MiB, and PNG / JPEG / WebP / GIF only - the server sniffs the leading bytes as well as the declared type, so a renamed file is rejected. A background and a background_color are mutually exclusive.

Public boards

board = client.public.board(share_token)   # no auth needed
print(board.project.name, len(board.cards))

The share token is the access check. Revoking sharing, or re-enabling it (which mints a fresh token), invalidates old links immediately and raises NotFoundError. The snapshot is trimmed: no workspace or ownership fields, and no card metadata beyond priority and assignee.

Webhooks and email-to-card

Board admins on a Pro plan can wire a board up to outbound webhooks and an intake email address. Both are 402 (PlanLimitError) on a Free board for every operation except delete/disable, which stay open so a downgraded workspace can still clean up:

catalogue = client.webhooks.list_for_project(project.id)
print(catalogue.event_catalogue)   # broadcast names you can filter on

hook = client.webhooks.create(
    project.id,
    url="https://hooks.slack.com/services/...",
    format="slack",
    events=["card.created", "card.archived"],
)
client.webhooks.update(hook.id, active=False)
client.webhooks.delete(hook.id)

hook.secret is the HMAC signing secret for "craaft"-format deliveries (X-Craaft-Signature); it is returned on every read, not redacted. Slack and Discord deliveries are unsigned.

status = client.inbound_email.get(project.id)
if not status.enabled:
    address = client.inbound_email.enable(project.id, target_column="todo")
    print(address.email)   # mail sent here becomes a card

client.inbound_email.update(project.id, rotate=True)     # mint a new address
client.inbound_email.update(project.id, target_column="")  # clear back to the first column
client.inbound_email.disable(project.id)

Notifications

The caller's inbox of mentions, assignments, comments, moves, updates and due reminders. List my mentions:

for n in client.notifications.iter_all(kind="mention"):
    who = ", ".join(a.name for a in n.actors)
    print(n.created_at, who, n.card.title, n.excerpt)

iter_all() follows the next_before cursor until the server returns None. To page by hand, pass next_before back as before=:

page = client.notifications.list(unread=True, limit=20)
while page.next_before is not None:
    page = client.notifications.list(unread=True, limit=20, before=page.next_before)

Always page with next_before, never the last row's created_at. Rows are grouped (unread comment, move and update notifications on one card merge into a single row with kind="group"), and a group carries its newest member's time, so the last row's time would skip rows. For the same reason a page can hold fewer than limit rows while older ones still exist.

kind= filters on one of mention, assigned, comment, moved, updated or due; "group" is not a valid filter, and an unknown kind raises ValidationError (400). limit must be between 1 and 50.

Seen and read are separate. Seen clears the unseen badge; read marks individual rows as handled:

unseen = client.notifications.count()

page = client.notifications.list()
if page.items:
    newest = page.items[0]
    client.notifications.mark_seen(newest.created_at)   # everything up to the newest row
    client.notifications.mark_read(newest.ids)          # one row, every id in a group

client.notifications.mark_read(card_id=card.id)         # everything on one card
client.notifications.read_all()

Each write returns the new unseen count. mark_read() needs ids (at most 200), card_id, or both, and raises ValueError before sending a request otherwise.

Bulk operations

Three methods batch card work into a single all-or-nothing transaction (max 100 items each). Items are dicts using the API's camelCase field names, passed through verbatim:

# Create - title and column required per item; position omitted = append.
# Unlike create_card, the assignee is NOT defaulted to the caller.
cards = client.projects.bulk_create_cards(project.id, [
    {"title": "Ship it", "column": "todo"},
    {"title": "Review copy", "column": "doing", "priority": "high", "tags": ["launch"]},
])

# Update - {"id": ...} plus any single-PATCH fields. A key set to None sends
# JSON null and CLEARS the field (dueDate, assignedUserId, size, priority);
# an absent key leaves the field alone.
client.cards.bulk_update([
    {"id": cards[0].id, "priority": "urgent"},
    {"id": cards[1].id, "dueDate": None},
])

# Move - sweep cards to a column on their own board, or to another board in
# the same workspace via target_project_id.
client.cards.bulk_move([c.id for c in cards], column="done")

One invalid item rolls back the whole batch; the raised ValidationError's message names the offending index (cards[3]: title is required). Bulk requests never send notification emails. datetime values under dueDate are serialized for you.

Checklists and milestones

Per-card checklists (any board member may write) and per-project milestones (board admins only - CraaftPermissionError on 403):

from datetime import date

item = client.cards.add_checklist_item(card.id, text="write tests")
client.checklist.update(item.id, done=True)

milestone = client.projects.add_milestone(project.id, name="Beta", due_on=date(2026, 9, 1))
client.milestones.update(milestone.id, achieved=True)   # stamps achieved_at once

Models

Frozen dataclasses, keyword-only. Highlights:

  • User, Project, Column, Card, Comment, Attachment
  • ChecklistItem, Milestone (due_on is a datetime.date; achieved_at is datetime | None)
  • SearchResult (cards.search()), UpcomingCard (cards.upcoming() and FocusResponse.due), AttentionCard, FocusResponse, HygieneCounts, CardEvent
  • BoardMember, BoardMemberGrant, WorkspaceMember, Invitation (consumed is True when a verified account was added to the workspace immediately instead of being left pending)
  • BoardTemplate (+ BoardTemplateColumn), ColumnArchiveResult
  • WebhookSubscription (+ WebhookDelivery), BoardWebhooks
  • InboundEmailAddress, InboundEmailStatus
  • Notification (+ NotificationCard, NotificationActor), NotificationPage (next_before is a string cursor, kept unparsed so it round-trips exactly)
  • ProjectExport (+ nested export types)
  • PublicBoard (+ PublicBoardProject, PublicBoardColumn, PublicBoardCard)

Card.size is an optional integer estimate. Card.priority is one of low, medium, high, urgent. Card.checklist_done / Card.checklist_total are denormalized checklist progress counts (0 when the card has no checklist). Card.following is whether the authenticated caller follows the card, so it differs per token for the same card. Card.archived_at is set only by projects.list_archived_cards(); None everywhere else, including a live card. Set metadata via cards.update() after create_card() - the create endpoint only accepts title, column, position, and optional description.

attachments.upload() sends multipart form data (max 25 MiB per file) and requires a Pro/Team workspace; use project.can_upload_attachments to check first.

CardSummary is deprecated: it used to be the shared return type of both search() and upcoming(), which was a bug since the two endpoints return different shapes (one has no due date/assignee/priority, the other has no description/updated_at/archived). It has been split into SearchResult and UpcomingCard; CardSummary is kept importable, and the two replacements keep its unused fields around too (always their default) so old code reading them doesn't raise AttributeError, but new code should use the two specific types.

Errors

from craaft import CraaftAPIError, NotFoundError, RateLimitError

try:
    client.projects.get("missing")
except NotFoundError:
    ...
except RateLimitError as e:
    sleep(e.retry_after or 1)
except CraaftAPIError as e:
    print(e.status_code, e.message, e.request_id)

Hierarchy: CraaftError is the root. API failures raise CraaftAPIError or one of its subclasses (AuthenticationError, PermissionError, NotFoundError, ConflictError, PlanLimitError, ValidationError, RateLimitError, ServerError). Network failures raise CraaftConnectionError or CraaftTimeoutError.

Retries

The client retries 429, 502, 503, 504, and network errors with exponential backoff and Retry-After-aware pauses. Writes (POST / PATCH / DELETE) skip 5xx retries by default, since the server may have applied the change before responding. Set RetryConfig(retry_writes_on_5xx=True) if your workload is safe to retry.

Logging

The client logs one DEBUG line per HTTP attempt (method, path, status, duration, attempt number) on the craaft logger. The auth header is never logged.

import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("craaft").setLevel(logging.DEBUG)

Reference

The API this client wraps is documented at craaft.io/openapi.yaml, which the running server publishes directly, so it always matches the deployment you are talking to. There is a PHP client at github.com/craaft/php-sdk covering the same surface.

Found a bug or a missing endpoint? Open an issue.

Development

pip install -e ".[dev]"
pytest
ruff check
mypy

License

MIT

Metadata

Release files for craaft 1.7.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for craaft 1.7.1
File Size Uploaded Provenance Actions
craaft-1.7.1.tar.gz 60.2 kB Without provenance Details for craaft-1.7.1.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for craaft 1.7.1
File Interpreter ABI Platform Actions
craaft-1.7.1-py3-none-any.whl Python 3 none any Details for craaft-1.7.1-py3-none-any.whl

Total release size: 105.6 kB

Release files / craaft-1.7.1.tar.gz

Download URL craaft-1.7.1.tar.gz
Size 60.2 kB
Tags Source
SHA-256 checksum
How to use checksums
e8404a18ddd709204fe8f07fc684413953211a4f490c14b31bf39d04440e8e7d
BLAKE2b-256 checksum
How to use checksums
28f1db79e87ba5abd5be7daeb9d1f811c0cbeb4875ae4bd36ceeb7ad8e03b725
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Release files / craaft-1.7.1-py3-none-any.whl

Download URL craaft-1.7.1-py3-none-any.whl
Size 45.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ccc81aae1558edeffc4f42331544c36d88d7a66857c9639492cf3ab798de106d
BLAKE2b-256 checksum
How to use checksums
e5b85931d67b7229404cd8d2afa3a8fb07b7af3d628df4ce8081928ad124d4bc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.16

Security for craaft 1.7.1

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page