Skip to content

Repository files navigation

Plane Python SDK

A comprehensive, type-annotated Python SDK for interacting with the Plane API. This SDK provides a clean, modern interface for all Plane API operations, following Python best practices with full type safety and Pydantic v2 integration.

Features

  • 🚀 Type-Safe: Full type annotations with Pydantic v2 models
  • 🔧 Modern Python: Built for Python 3.10+ with modern typing idioms
  • 🛡️ Error Handling: Comprehensive error types and exception handling
  • 🔄 Retry Logic: Built-in retry mechanism with configurable backoff
  • 📦 Resource-Based: Clean resource-based API organization
  • 🎯 Comprehensive: Support for all major Plane API endpoints
  • Synchronous: Uses requests with connection pooling

Breaking Changes (v0.2.0 vs v0.1.x)

This SDK (v0.2.0) replaces the v0.1.x OpenAPI-generated client and introduces intentional breaking changes for a cleaner, type-safe developer experience.

  • Authentication and client

    • New PlaneClient(base_url, api_key | access_token) replaces OpenAPI Configuration/ApiClient usage
    • Exactly one of api_key or access_token is required; providing both raises a ConfigurationError
    • base_url should NOT include /api/v1; the SDK appends /api/v1 automatically
  • HTTP headers

    • API key header standardized to X-Api-Key; access tokens use Authorization: Bearer <token>
  • Resource paths and naming

    • All paths use work-items instead of v0.1.x issues
    • Sub-resources are grouped under client.work_items.<subresource>
  • Method names

    • Methods are standardized across resources: list, create, retrieve, update, delete
    • Replaces verbose, OpenAPI-generated method names
  • Models and DTOs

    • Uses Pydantic v2 with: response models extra="allow"; Create*/Update* DTOs extra="ignore"
    • Separate DTOs for create/update: Create* and Update*
    • Field naming is normalized
  • Pagination shape

    • Paginated responses now expose: results, total_count, next_page_number, prev_page_number
    • This replaces v0.1.x shapes that included different field names
  • Query parameters

    • Typed query params via models like WorkItemQueryParams and RetrieveQueryParams
    • Common fields include per_page, page, order_by, expand
  • Errors

    • Raises HttpError(message, status_code, response) on non-2xx responses
    • Configuration validation errors raise ConfigurationError
  • Imports and organization

    • Import models from plane.models.<resource>
    • No OpenAPI *Api classes; use resource objects from PlaneClient
  • Trailing slashes

    • All endpoints include trailing / by design; the SDK enforces this consistently

Migration example (v0.1.x → v0.2.0):

# v0.1.x (OpenAPI-generated)fromplaneimportConfiguration, ApiClientfromplane.apisimportWorkItemsApicfg=Configuration(host="https://api.plane.so")
cfg.api_key['X-API-Key'] ="<api-key>"api=WorkItemsApi(ApiClient(cfg))
api.list_work_items(slug, project_id=project_id)
# v0.2.0 (this SDK)fromplane.clientimportPlaneClientfromplane.models.query_paramsimportWorkItemQueryParamsclient=PlaneClient(base_url="https://api.plane.so", api_key="<api-key>")
client.work_items.list(
workspace_slug=slug,
project_id=project_id,
params=WorkItemQueryParams(per_page=20, order_by="-created_at")
)

Installation

pip install plane-sdk

Quick Start

Authentication

⚠️Required: You must provide exactly one of api_key or access_token for authentication.

importosfromplane.clientimportPlaneClientfromplane.errorsimportConfigurationError# Using API keyclient=PlaneClient(
base_url="https://api.plane.so",
api_key=os.environ["PLANE_API_KEY"]
)
# OR using access token (not both)client=PlaneClient(
base_url="https://api.plane.so",
access_token=os.environ["PLANE_ACCESS_TOKEN"]
)
# Raises ConfigurationError if neither or both are provided

OAuth Authentication

The SDK also supports OAuth 2.0 authentication for more advanced use cases:

fromplaneimportOAuthClient# Initialize OAuth clientoauth_client=OAuthClient(
base_url="https://api.plane.so",
client_id="your_client_id",
client_secret="your_client_secret"
)
# Authorization Code Flow (for web applications)# Step 1: Get authorization URLauth_url=oauth_client.get_authorization_url(
redirect_uri="https://your-app.com/callback",
scope="read write",
state="random_state_string"
)
# Step 2: Exchange authorization code for tokentoken=oauth_client.exchange_code(
code="authorization_code_from_callback",
redirect_uri="https://your-app.com/callback"
)
# Step 3: Use the access tokenclient=PlaneClient(
base_url="https://api.plane.so",
access_token=token.access_token
)
# Client Credentials Flow (for server-to-server)token=oauth_client.get_client_credentials_token(
scope="read write",
app_installation_id="optional_workspace_app_installation_id"
)
# Refresh expired tokensnew_token=oauth_client.refresh_token(token.refresh_token)
# Revoke tokensoauth_client.revoke_token(token.access_token)

For detailed OAuth examples, see examples/oauth_example.py.

Basic Usage

# List projects in a workspaceprojects=client.projects.list("my-workspace")
# Create a work itemfromplane.models.work_itemsimportCreateWorkItemwork_item=client.work_items.create(
workspace_slug="my-workspace",
project_id="project-id",
data=CreateWorkItem(name="New task", state_id="state-id")
)
# Retrieve a work item with parametersfromplane.models.query_paramsimportRetrieveQueryParamswork_item=client.work_items.retrieve(
workspace_slug="my-workspace",
project_id="project-id",
work_item_id="work-item-id",
params=RetrieveQueryParams(expand="assignees,labels,state")
)
# List work items with pagination and filteringfromplane.models.query_paramsimportWorkItemQueryParamswork_items=client.work_items.list(
workspace_slug="my-workspace",
project_id="project-id",
params=WorkItemQueryParams(per_page=50, order_by="-created_at")
)

Architecture

Client Structure

The SDK is organized around a central PlaneClient that provides access to various resource classes:

fromplane.clientimportPlaneClientclient=PlaneClient(
base_url="https://api.plane.so",
api_key="your-api-key"
)
# Access different resourcesclient.users# User managementclient.workspaces# Workspace operationsclient.projects# Project managementclient.work_items# Work item operationsclient.cycles# Cycle managementclient.modules# Module managementclient.labels# Label managementclient.states# State/workflow managementclient.work_item_types# Work item type managementclient.work_item_properties# Custom propertiesclient.epics# Epic managementclient.intake# Intake managementclient.pages# Page managementclient.customers# Customer managementclient.teamspaces# Teamspace managementclient.stickies# Sticky managementclient.initiatives# Initiative management

Resource Organization

All API resources extend a shared BaseResource class that handles:

  • HTTP request/response logic
  • Authentication headers
  • Error handling and retry logic
  • URL building with proper path formatting

Type Safety

The SDK uses Pydantic v2 models for all data structures:

  • Request models
  • Response models
  • Query parameter models

Note: Response models are configured with extra="allow" to be forward-compatible with new fields. Create*/Update* DTOs and query parameter models use extra="ignore".

Available Resources

Core Resources

Users

# Get current userme=client.users.get_me()
# Retrieve a specific useruser=client.users.retrieve(user_id)
# List all usersusers=client.users.list()

Workspaces

# Get workspace membersmembers=client.workspaces.get_members(workspace_slug)
# Filter members (all filters combine with AND; role_slug is exact, text fields# match case-insensitive contains)fromplane.models.query_paramsimportMemberQueryParams, MemberListQueryParamsadmins=client.workspaces.get_members(
workspace_slug,
params=MemberQueryParams(role_slug="admin", is_active=True),
)
# Paginated "lite" list — follow next_cursor until next_page_results is Falsepaginated_members=client.workspaces.get_members_lite(
workspace_slug,
params=MemberListQueryParams(per_page=1000),
)
all_members=list(paginated_members.results)
whilepaginated_members.next_page_results:
paginated_members=client.workspaces.get_members_lite(
workspace_slug,
params=MemberListQueryParams(per_page=1000, cursor=paginated_members.next_cursor),
)
all_members.extend(paginated_members.results)
# Project-role distribution — member counts per role across all active# (non-archived) projects in the workspace (built-in + custom roles)distribution=client.workspaces.get_project_role_distribution(workspace_slug)
print(distribution.total_memberships, distribution.total_distinct_members)
forroleindistribution.roles:
print(role.slug, role.membership_count, role.distinct_member_count)

Roles

# List all role definitions (workspace + project), paginated envelopepage=client.roles.list(workspace_slug)
forroleinpage.results:
print(role.namespace, role.slug, role.name)
# Only workspace-level roles (Owner / Admin / Member / Guest)workspace_roles=client.roles.list(workspace_slug, namespace="workspace")
# Only project-role definitions (Admin / Contributor / Commenter / Guest).# These are shared across every project in the workspace — there is no# per-project roles endpoint.project_roles=client.roles.list(workspace_slug, namespace="project")
# Retrieve a single role by idrole=client.roles.retrieve(workspace_slug, role_id)

slug is the stable identifier to use in code, but it is not globally unique (admin/guest exist in both namespaces) — key roles by (namespace, slug) when indexing them.

Project Management

Projects

# Create a projectfromplane.models.projectsimportCreateProjectproject=client.projects.create(
workspace_slug="my-workspace",
data=CreateProject(
name="My Project",
identifier="MP",
description="Project description"
)
)
# List projectsprojects=client.projects.list(workspace_slug="my-workspace")
# Retrieve a projectproject=client.projects.retrieve(workspace_slug, project_id)
# Update a projectfromplane.models.projectsimportUpdateProjectproject=client.projects.update(
workspace_slug, project_id,
data=UpdateProject(name="Updated Name")
)
# Delete a projectclient.projects.delete(workspace_slug, project_id)
# Get worklog summaryworklog_summary=client.projects.get_worklog_summary(workspace_slug, project_id)
# Get project membersmembers=client.projects.get_members(workspace_slug, project_id)
# Filter project members (same filters as workspace members)fromplane.models.query_paramsimportMemberQueryParams, MemberListQueryParamsmembers=client.projects.get_members(
workspace_slug, project_id,
params=MemberQueryParams(display_name="ana", is_bot=False),
)
# Paginated "lite" listmembers=client.projects.get_members_lite(
workspace_slug, project_id,
params=MemberListQueryParams(per_page=1000),
)
# Paginated "lite" project list (id, identifier, name, icon/emoji, description,# cover image, archived_at) — for pickers/reference lookups.fromplane.models.query_paramsimportProjectLiteListQueryParamslite=client.projects.list_lite(
workspace_slug,
params=ProjectLiteListQueryParams(per_page=1000, order_by="-created_at"),
)
forpinlite.results:
print(p.identifier, p.name)
# NOTE: archived projects are now EXCLUDED by default. Pass include_archived=True# to restore the previous behavior of listing archived projects too.lite=client.projects.list_lite(
workspace_slug,
params=ProjectLiteListQueryParams(include_archived=True),
)

Work Items

# Create a work itemfromplane.models.work_itemsimportCreateWorkItemwork_item=client.work_items.create(
workspace_slug="my-workspace",
project_id="project-id",
data=CreateWorkItem(
name="Fix login bug",
description_html="<p>Fix the login issue</p>",
state_id="state-id",
priority="high"
)
)
# Retrieve a work itemfromplane.models.query_paramsimportRetrieveQueryParamswork_item=client.work_items.retrieve(
workspace_slug, project_id, work_item_id,
params=RetrieveQueryParams(expand="assignees,labels,state")
)
# List work itemsfromplane.models.query_paramsimportWorkItemQueryParamswork_items=client.work_items.list(
workspace_slug, project_id,
params=WorkItemQueryParams(per_page=50, order_by="-created_at")
)
# Update a work itemfromplane.models.work_itemsimportUpdateWorkItemwork_item=client.work_items.update(
workspace_slug, project_id, work_item_id,
data=UpdateWorkItem(priority="low", state_id="new-state-id")
)
# Delete a work itemclient.work_items.delete(workspace_slug, project_id, work_item_id)
# Search work itemsresults=client.work_items.search(
workspace_slug, project_id,
query="bug fix"
)

Work Item Sub-Resources

# Commentscomments=client.work_items.comments.list(workspace_slug, project_id, work_item_id)
comment=client.work_items.comments.create(workspace_slug, project_id, work_item_id, data)
comment=client.work_items.comments.retrieve(workspace_slug, project_id, work_item_id, comment_id)
comment=client.work_items.comments.update(workspace_slug, project_id, work_item_id, comment_id, data)
client.work_items.comments.delete(workspace_slug, project_id, work_item_id, comment_id)
# Attachmentsattachments=client.work_items.attachments.list(workspace_slug, project_id, work_item_id)
attachment=client.work_items.attachments.create(workspace_slug, project_id, work_item_id, data)
attachment=client.work_items.attachments.retrieve(workspace_slug, project_id, work_item_id, attachment_id)
client.work_items.attachments.delete(workspace_slug, project_id, work_item_id, attachment_id)
# Linkslinks=client.work_items.links.list(workspace_slug, project_id, work_item_id)
link=client.work_items.links.create(workspace_slug, project_id, work_item_id, data)
link=client.work_items.links.retrieve(workspace_slug, project_id, work_item_id, link_id)
link=client.work_items.links.update(workspace_slug, project_id, work_item_id, link_id, data)
client.work_items.links.delete(workspace_slug, project_id, work_item_id, link_id)
# Relationsrelations=client.work_items.relations.list(workspace_slug, project_id, work_item_id)
relation=client.work_items.relations.create(workspace_slug, project_id, work_item_id, data)
# Activitiesactivities=client.work_items.activities.list(workspace_slug, project_id, work_item_id)
# Work Logswork_logs=client.work_items.work_logs.list(workspace_slug, project_id, work_item_id)
work_log=client.work_items.work_logs.create(workspace_slug, project_id, work_item_id, data)
work_log=client.work_items.work_logs.retrieve(workspace_slug, project_id, work_item_id, work_log_id)
work_log=client.work_items.work_logs.update(workspace_slug, project_id, work_item_id, work_log_id, data)
client.work_items.work_logs.delete(workspace_slug, project_id, work_item_id, work_log_id)

Cycles

# Create a cyclefromplane.models.cyclesimportCreateCyclecycle=client.cycles.create(
workspace_slug, project_id,
data=CreateCycle(
name="Sprint 1",
start_date="2024-01-01",
end_date="2024-01-15",
owned_by="user-id"
)
)
# List cyclescycles=client.cycles.list(workspace_slug, project_id)
# Filter cycles by status: current | upcoming | completed | draft | incomplete.# `status` is canonical; `cycle_view` is a deprecated alias (status wins if both set).fromplane.models.query_paramsimportCycleListQueryParamsupcoming=client.cycles.list(
workspace_slug, project_id,
params=CycleListQueryParams(status="upcoming"),
)
forcinupcoming.results: # paginated envelopeprint(c.name)
# NOTE: status="current" is a special case — the API returns a BARE LIST of cycles# (not the paginated envelope). list() returns whichever shape the server sends.current=client.cycles.list(
workspace_slug, project_id,
params=CycleListQueryParams(status="current"),
)
forcincurrent: # plain list[Cycle]print(c.name)
# Retrieve a cyclecycle=client.cycles.retrieve(workspace_slug, project_id, cycle_id)
# Update a cyclefromplane.models.cyclesimportUpdateCyclecycle=client.cycles.update(
workspace_slug, project_id, cycle_id,
data=UpdateCycle(name="Updated Sprint")
)
# Delete a cycleclient.cycles.delete(workspace_slug, project_id, cycle_id)
# List archived cyclesarchived=client.cycles.list_archived(workspace_slug, project_id)
# Paginated "lite" cycle list (full cycle fields minus issue-count metrics).# Supports a status filter: current | upcoming | completed | draft | incomplete# (omit for all). The lite endpoint takes only `status` (no `cycle_view` alias)# and ALWAYS paginates — even for status="current".fromplane.models.query_paramsimportCycleLiteListQueryParamslite=client.cycles.list_lite(
workspace_slug, project_id,
params=CycleLiteListQueryParams(status="current", per_page=1000),
)
forcinlite.results:
print(c.name)
# Add work items to cyclefromplane.models.cyclesimportAddWorkItemsToCycleRequestclient.cycles.add_work_items(
workspace_slug, project_id, cycle_id,
data=AddWorkItemsToCycleRequest(issues=[work_item_id])
)
# Remove work item from cycleclient.cycles.remove_work_item(workspace_slug, project_id, cycle_id, work_item_id)
# List work items in cyclecycle_items=client.cycles.list_work_items(workspace_slug, project_id, cycle_id)
# Transfer work items between cyclesfromplane.models.cyclesimportTransferCycleWorkItemsRequestclient.cycles.transfer_work_items(
workspace_slug, project_id, cycle_id,
data=TransferCycleWorkItemsRequest(new_cycle_id="other-cycle-id")
)
# Archive/unarchive cyclesclient.cycles.archive(workspace_slug, project_id, cycle_id)
client.cycles.unarchive(workspace_slug, project_id, cycle_id)

Modules

# Create a modulefromplane.models.modulesimportCreateModulemodule=client.modules.create(
workspace_slug, project_id,
data=CreateModule(name="Auth Module")
)
# List modulesmodules=client.modules.list(workspace_slug, project_id)
# Retrieve a modulemodule=client.modules.retrieve(workspace_slug, project_id, module_id)
# Update a modulefromplane.models.modulesimportUpdateModulemodule=client.modules.update(
workspace_slug, project_id, module_id,
data=UpdateModule(name="Updated Module")
)
# Delete a moduleclient.modules.delete(workspace_slug, project_id, module_id)
# List archived modulesarchived=client.modules.list_archived(workspace_slug, project_id)
# Paginated "lite" module list (full module fields minus issue-count metrics)fromplane.models.query_paramsimportLiteListQueryParamslite=client.modules.list_lite(
workspace_slug, project_id,
params=LiteListQueryParams(per_page=1000, order_by="-created_at"),
)
forminlite.results:
print(m.name)
# Add work items to modulefromplane.models.modulesimportAddWorkItemsToModuleRequestclient.modules.add_work_items(
workspace_slug, project_id, module_id,
data=AddWorkItemsToModuleRequest(issues=[work_item_id])
)
# Remove work item from moduleclient.modules.remove_work_item(workspace_slug, project_id, module_id, work_item_id)
# List work items in modulemodule_items=client.modules.list_work_items(workspace_slug, project_id, module_id)
# Archive/unarchive modulesclient.modules.archive(workspace_slug, project_id, module_id)
client.modules.unarchive(workspace_slug, project_id, module_id)

States

# Create a statefromplane.models.statesimportCreateStatestate=client.states.create(
workspace_slug, project_id,
data=CreateState(
name="In Progress",
color="#3b82f6",
group="started"
)
)
# List statesstates=client.states.list(workspace_slug, project_id)
# Retrieve a statestate=client.states.retrieve(workspace_slug, project_id, state_id)
# Update a statefromplane.models.statesimportUpdateStatestate=client.states.update(
workspace_slug, project_id, state_id,
data=UpdateState(name="Updated Status")
)
# Delete a stateclient.states.delete(workspace_slug, project_id, state_id)

Workspace States

Workspace-level work-item states. Reads are dual-mode: under workspace governance they serve the workspace states catalog; in ungoverned workspaces they aggregate the states of every project the caller can access. Writes require the workspace to own states and workflows (check client.workspaces.get_features(workspace_slug).states_owned_by_workspace).

# List states at workspace scope (works in both modes)states=client.workspace_states.list(workspace_slug)
# Create a workspace (catalog) state — governed workspaces onlyfromplane.models.statesimportCreateWorkspaceStatestate=client.workspace_states.create(
workspace_slug,
data=CreateWorkspaceState(name="In Review", color="#3b82f6", group="started"),
)
# Retrieve / update / deletestate=client.workspace_states.retrieve(workspace_slug, state_id)
fromplane.models.statesimportUpdateWorkspaceStatestate=client.workspace_states.update(
workspace_slug, state_id, data=UpdateWorkspaceState(color="#22c55e")
)
client.workspace_states.delete(workspace_slug, state_id)

Labels

# Create a labelfromplane.models.labelsimportCreateLabellabel=client.labels.create(
workspace_slug, project_id,
data=CreateLabel(name="Bug", color="#ef4444")
)
# List labelslabels=client.labels.list(workspace_slug, project_id)
# Retrieve a labellabel=client.labels.retrieve(workspace_slug, project_id, label_id)
# Update a labelfromplane.models.labelsimportUpdateLabellabel=client.labels.update(
workspace_slug, project_id, label_id,
data=UpdateLabel(name="Updated Label")
)
# Delete a labelclient.labels.delete(workspace_slug, project_id, label_id)

Work Item Configuration

Work Item Types

# Create a work item typefromplane.models.work_item_typesimportCreateWorkItemTypewit=client.work_item_types.create(
workspace_slug, project_id,
data=CreateWorkItemType(name="Story")
)
# List work item typestypes=client.work_item_types.list(workspace_slug, project_id)
# Retrieve a work item typewit=client.work_item_types.retrieve(workspace_slug, project_id, type_id)
# Update a work item typefromplane.models.work_item_typesimportUpdateWorkItemTypewit=client.work_item_types.update(
workspace_slug, project_id, type_id,
data=UpdateWorkItemType(name="Updated Type")
)
# Delete a work item typeclient.work_item_types.delete(workspace_slug, project_id, type_id)

Workspace Workflows

The workspace workflow catalog (workspace governance). list is dual-mode; all writes require the workspace to own states and workflows.

# List workspace workflowsworkflows=client.workspace_workflows.list(workspace_slug)
# Create a workflow draft, then configure its chain from catalog statesfromplane.models.statesimportCreateWorkspaceStatefromplane.models.workspace_workflowsimport (
AddWorkspaceWorkflowStates,
CreateWorkspaceWorkflow,
CreateWorkspaceWorkflowTransition,
)
state_a=client.workspace_states.create(
workspace_slug, data=CreateWorkspaceState(name="Todo", color="#94a3b8", group="unstarted")
)
state_b=client.workspace_states.create(
workspace_slug, data=CreateWorkspaceState(name="Doing", color="#3b82f6", group="started")
)
workflow=client.workspace_workflows.create(
workspace_slug, data=CreateWorkspaceWorkflow(name="Engineering")
)
client.workspace_workflows.states.add(
workspace_slug,
workflow.id,
data=AddWorkspaceWorkflowStates(state_ids=[state_a.id, state_b.id]),
)
client.workspace_workflows.states.mark_default(workspace_slug, workflow.id, state_a.id)
# Transitionstransition=client.workspace_workflows.transitions.create(
workspace_slug,
workflow.id,
data=CreateWorkspaceWorkflowTransition(state_id=state_a.id, transition_state_id=state_b.id),
)
# Full chain, usage report, and activity logworkflow=client.workspace_workflows.retrieve(workspace_slug, workflow.id)
usage=client.workspace_workflows.usage(workspace_slug, workflow.id)
activities=client.workspace_workflows.activities(workspace_slug, workflow.id)
# Transition hooks (validation/action hooks, webhook secrets, executions)hooks=client.workspace_workflows.hooks.list(workspace_slug, workflow.id, transition.id)

Work Item Type Governance

Governs which workflows a workspace-level work item type may use (any / constrained / required modes, allowlists, and per-project pins). Workspace governance only.

# type_id: UUID of a workspace work item type; workflow_id: UUID of a# workspace workflow (e.g. workflow.id from the example above)# Read and change a type's governancegovernance=client.work_item_type_governance.retrieve(workspace_slug, type_id)
fromplane.models.work_item_type_governanceimportUpdateTypeGovernancegovernance=client.work_item_type_governance.update(
workspace_slug,
type_id,
data=UpdateTypeGovernance(mode="constrained", workflow_ids=[workflow_id]),
)
# Dry-run the impact firstfromplane.models.work_item_type_governanceimportTypeGovernancePreviewRequestpreview=client.work_item_type_governance.preview(
workspace_slug,
type_id,
data=TypeGovernancePreviewRequest(mode="required", required_workflow_id=workflow_id),
)
# Per-project pinspins=client.work_item_type_governance.pins.list(workspace_slug, type_id)
# Project-side view: each type's effective workflow, and the project's pickentries=client.work_item_type_governance.project_workflows.list(workspace_slug, project_id)
fromplane.models.work_item_type_governanceimportSetProjectWorkflowPickclient.work_item_type_governance.project_workflows.update_pick(
workspace_slug, project_id, type_id, data=SetProjectWorkflowPick(workflow_id=workflow_id)
)

Work Item Properties

# Create a propertyfromplane.models.work_item_propertiesimportCreateWorkItemPropertyprop=client.work_item_properties.create(
workspace_slug, project_id, work_item_type_id,
data=CreateWorkItemProperty(name="Severity")
)
# List propertiesproperties=client.work_item_properties.list(workspace_slug, project_id, work_item_type_id)
# Retrieve a propertyprop=client.work_item_properties.retrieve(workspace_slug, project_id, work_item_type_id, property_id)
# Update a propertyfromplane.models.work_item_propertiesimportUpdateWorkItemPropertyprop=client.work_item_properties.update(
workspace_slug, project_id, work_item_type_id, property_id,
data=UpdateWorkItemProperty(name="Updated Property")
)
# Delete a propertyclient.work_item_properties.delete(workspace_slug, project_id, work_item_type_id, property_id)

Additional Resources

Epics

# List epicsepics=client.epics.list(workspace_slug, project_id)
# Retrieve an epicepic=client.epics.retrieve(workspace_slug, project_id, epic_id)

Intake

# Create intake issuefromplane.models.intakeimportCreateIntakeintake=client.intake.create(
workspace_slug, project_id,
data=CreateIntake(name="Customer request")
)
# List intake issuesintake_items=client.intake.list(workspace_slug, project_id)
# Retrieve intake issueintake=client.intake.retrieve(workspace_slug, project_id, intake_id)
# Update intake issuefromplane.models.intakeimportUpdateIntakeintake=client.intake.update(
workspace_slug, project_id, intake_id,
data=UpdateIntake(status="completed")
)
# Delete intake issueclient.intake.delete(workspace_slug, project_id, intake_id)

Pages

# List workspace pagespages=client.pages.list_workspace_pages(workspace_slug)
# List project pagespages=client.pages.list_project_pages(workspace_slug, project_id)
# Retrieve a workspace pagepage=client.pages.retrieve_workspace_page(workspace_slug, page_id)
# Retrieve a project pagepage=client.pages.retrieve_project_page(workspace_slug, project_id, page_id)

Customers

# List customerscustomers=client.customers.list(workspace_slug)
# Create a customerfromplane.models.customersimportCreateCustomercustomer=client.customers.create(
workspace_slug,
data=CreateCustomer(name="Acme Inc")
)
# Retrieve a customercustomer=client.customers.retrieve(workspace_slug, customer_id)
# Update a customerfromplane.models.customersimportUpdateCustomercustomer=client.customers.update(
workspace_slug, customer_id,
data=UpdateCustomer(name="Updated Name")
)
# Delete a customerclient.customers.delete(workspace_slug, customer_id)
# Customer propertiesproperties=client.customers.properties.list(workspace_slug, customer_id)
property=client.customers.properties.create(workspace_slug, customer_id, data)
# Customer requestsrequests=client.customers.requests.list(workspace_slug, customer_id)

Data Models

The SDK provides comprehensive Pydantic v2 models for all API operations.

Query Parameters

  • BaseQueryParams - Base query parameters
  • PaginatedQueryParams - Cursor-based pagination support (cursor, per_page)
  • WorkItemQueryParams - Work item specific queries (expand, order_by, filters, pql, etc.)
  • RetrieveQueryParams - Retrieve operations (expand, fields, etc.)

Filtering work items

WorkItemQueryParams accepts two filter inputs that map to the same backend filter engine:

  • filters — a structured filter expression (dict). Supports nested and / or / not groups and field operators (__in, __gte, __range, __icontains, etc.). The SDK JSON-encodes this into the filters= query parameter.
  • pql — a Plane Query Language string. Human-readable alternative with the same expressive power.
fromplane.models.query_paramsimportWorkItemQueryParams# Project-scoped, structured filtersclient.work_items.list(
"my-workspace",
"project-id",
params=WorkItemQueryParams(
filters={"and": [
{"priority": "urgent"},
{"state_group__in": ["unstarted", "started"]},
]},
order_by="-created_at",
per_page=50,
),
)
# Project-scoped, PQLclient.work_items.list(
"my-workspace",
"project-id",
params=WorkItemQueryParams(pql='priority = "urgent" AND assignee = currentUser()'),
)
# Workspace-scoped — spans every project the caller can view, with# per-project authorization honored server-sideclient.work_items.list_workspace(
"my-workspace",
params=WorkItemQueryParams(filters={"priority": "urgent"}),
)

The same filters and pql query parameters also work on list_archived, cycles.list_work_items, and modules.list_work_items.

Response Models

Paginated responses follow the pattern Paginated<Resource>Response and include:

  • results - Array of resource objects
  • total_count - Total number of results
  • next_page_number - Next page number (if applicable)
  • prev_page_number - Previous page number (if applicable)

Error Handling

The SDK provides comprehensive error handling with specific exception types:

fromplane.errorsimportPlaneError, ConfigurationError, HttpError# Configuration errorstry:
client=PlaneClient(base_url="https://api.plane.so")
# Missing both api_key and access_tokenexceptConfigurationErrorase:
print(f"Configuration error: {e}")
# HTTP errorstry:
work_item=client.work_items.retrieve("workspace", "project", "invalid-id")
exceptHttpErrorase:
print(f"HTTP error {e.status_code}: {e}")
print(f"Response: {e.response}")

Error Types

  • PlaneError - Base exception class with optional status_code
  • ConfigurationError - Invalid client configuration (missing credentials or both auth methods provided)
  • HttpError - HTTP request/response errors with status code and response body

Configuration

Basic Configuration

fromplane.clientimportPlaneClientclient=PlaneClient(
base_url="https://api.plane.so",
api_key="your-api-key"
)

Advanced Configuration

fromplane.configimportConfiguration, RetryConfigfromplane.clientimportPlaneClient# Custom retry configurationretry_config=RetryConfig(
total=5, # Number of retriesbackoff_factor=0.5, # Backoff multiplierstatus_forcelist=(429, 500, 502, 503, 504) # Retry on these status codes
)
# Create client with custom configclient=PlaneClient(
base_url="https://api.plane.so",
api_key="your-api-key",
timeout=60.0, # Request timeout in secondsretry=retry_config# Optional retry config
)

Configuration Options

OptionTypeDefaultDescription
base_urlstrRequiredAPI base URL
api_keystrOptionalAPI key for authentication
access_tokenstrOptionalAccess token for authentication
timeoutfloat | tuple[float, float]30.0Request timeout in seconds
retryRetryConfigNoneRetry configuration

Note: Provide exactly one of api_key or access_token.

Examples

Complete Workflow Example

fromplane.clientimportPlaneClientfromplane.models.projectsimportCreateProjectfromplane.models.work_itemsimportCreateWorkItemfromplane.models.statesimportCreateStatefromplane.models.labelsimportCreateLabelfromplane.models.query_paramsimportWorkItemQueryParamsclient=PlaneClient(
base_url="https://api.plane.so",
api_key="your-api-key"
)
# Create a projectproject=client.projects.create(
workspace_slug="my-workspace",
data=CreateProject(
name="My New Project",
identifier="MNP",
description="A project created with the Python SDK"
)
)
# Create a statestate=client.states.create(
workspace_slug="my-workspace",
project_id=project.id,
data=CreateState(
name="In Progress",
color="#3b82f6",
group="started"
)
)
# Create a labellabel=client.labels.create(
workspace_slug="my-workspace",
project_id=project.id,
data=CreateLabel(name="Bug", color="#ef4444")
)
# Create a work itemwork_item=client.work_items.create(
workspace_slug="my-workspace",
project_id=project.id,
data=CreateWorkItem(
name="Fix authentication bug",
description_html="<p>Fix the authentication issue in the login flow</p>",
priority="high",
state_id=state.id,
labels=[label.id]
)
)
# List work items with filterswork_items=client.work_items.list(
workspace_slug="my-workspace",
project_id=project.id,
params=WorkItemQueryParams(per_page=20, order_by="-created_at")
)
print(f"Created work item: {work_item.name}")
print(f"Total work items: {len(work_items.results)}")

Working with Cycles

fromplane.models.cyclesimportCreateCycle, AddWorkItemsToCycleRequest# Create a cyclecycle=client.cycles.create(
workspace_slug="my-workspace",
project_id=project.id,
data=CreateCycle(
name="Sprint 1",
description="First sprint of the project",
start_date="2024-01-01",
end_date="2024-01-15",
owned_by="user-id"
)
)
# Add work items to cycleclient.cycles.add_work_items(
workspace_slug="my-workspace",
project_id=project.id,
cycle_id=cycle.id,
data=AddWorkItemsToCycleRequest(issues=[work_item.id])
)
# List cycle work itemscycle_work_items=client.cycles.list_work_items(
workspace_slug="my-workspace",
project_id=project.id,
cycle_id=cycle.id
)
print(f"Cycle: {cycle.name}")
print(f"Work items in cycle: {len(cycle_work_items.results)}")

Working with Comments and Attachments

fromplane.models.work_itemsimportCreateWorkItemComment# Add a commentcomment=client.work_items.comments.create(
workspace_slug="my-workspace",
project_id=project.id,
work_item_id=work_item.id,
data=CreateWorkItemComment(
comment_html="<p>This is a comment on the work item</p>",
access="INTERNAL"
)
)
# List commentscomments=client.work_items.comments.list(
workspace_slug="my-workspace",
project_id=project.id,
work_item_id=work_item.id
)
print(f"Total comments: {len(comments.results)}")
# Upload an attachmentattachment=client.work_items.attachments.create(
workspace_slug="my-workspace",
project_id=project.id,
work_item_id=work_item.id,
data={
"asset": "file", # URL to file or file path"attributes": {"name": "screenshot.png"}
}
)
print(f"Attachment ID: {attachment.id}")

Requirements

  • Python 3.10+
  • requests >= 2.31.0
  • pydantic >= 2.4.0

Development

Setup

git clone <repository-url>cd plane-python-sdk
pip install -e ".[dev]"

Running Tests

# Run all tests
pytest
# Run specific test file
pytest tests/unit/test_work_items.py
# Run with coverage
pytest --cov=plane tests/

Code Quality

The project uses:

  • Black for code formatting
  • Ruff for linting (rules: E, F, I, UP, B)
  • MyPy for type checking
  • Pytest for testing

Run pre-commit checks:

pre-commit run --all-files

Project Structure

plane-python-sdk/
├── plane/
│ ├── __init__.py
│ ├── client.py # Main PlaneClient
│ ├── config.py # Configuration classes
│ ├── api/ # API resource classes
│ │ ├── base_resource.py # Base class for all resources
│ │ ├── work_items/ # Work item sub-resources
│ │ ├── work_item_properties/
│ │ ├── customers/
│ │ └── ...
│ ├── models/ # Pydantic models
│ │ ├── work_items.py
│ │ ├── projects.py
│ │ ├── query_params.py
│ │ ├── enums.py
│ │ └── ...
│ └── errors/ # Exception classes
│ └── errors.py
├── tests/
│ ├── unit/ # Unit tests
│ └── scripts/ # Integration test scripts
├── pyproject.toml
├── README.md
└── requirements.txt

License

MIT License - see LICENSE file for details.

Support

For issues and questions:


Note: This SDK is designed to work with Plane's REST API. Make sure you have the appropriate API credentials and permissions for the operations you're trying to perform.

About

Python SDK for plane.so

Resources

Stars

15 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages