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.
- 🚀 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
requestswith connection pooling
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 OpenAPIConfiguration/ApiClientusage - Exactly one of
api_keyoraccess_tokenis required; providing both raises aConfigurationError base_urlshould NOT include/api/v1; the SDK appends/api/v1automatically
- New
HTTP headers
- API key header standardized to
X-Api-Key; access tokens useAuthorization: Bearer <token>
- API key header standardized to
Resource paths and naming
- All paths use
work-itemsinstead of v0.1.xissues - Sub-resources are grouped under
client.work_items.<subresource>
- All paths use
Method names
- Methods are standardized across resources:
list,create,retrieve,update,delete - Replaces verbose, OpenAPI-generated method names
- Methods are standardized across resources:
Models and DTOs
- Uses Pydantic v2 with: response models
extra="allow"; Create*/Update* DTOsextra="ignore" - Separate DTOs for create/update:
Create*andUpdate* - Field naming is normalized
- Uses Pydantic v2 with: response models
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
- Paginated responses now expose:
Query parameters
- Typed query params via models like
WorkItemQueryParamsandRetrieveQueryParams - Common fields include
per_page,page,order_by,expand
- Typed query params via models like
Errors
- Raises
HttpError(message, status_code, response)on non-2xx responses - Configuration validation errors raise
ConfigurationError
- Raises
Imports and organization
- Import models from
plane.models.<resource> - No OpenAPI
*Apiclasses; use resource objects fromPlaneClient
- Import models from
Trailing slashes
- All endpoints include trailing
/by design; the SDK enforces this consistently
- All endpoints include trailing
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")
)pip install plane-sdkapi_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 providedThe 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.
# 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")
)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 managementAll 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
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".
# Get current userme=client.users.get_me()
# Retrieve a specific useruser=client.users.retrieve(user_id)
# List all usersusers=client.users.list()# 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)# 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)
slugis the stable identifier to use in code, but it is not globally unique (admin/guestexist in both namespaces) — key roles by(namespace, slug)when indexing them.
# 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),
)# 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"
)# 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)# 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)# 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)# 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-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)# 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)# 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)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)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)
)# 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)# List epicsepics=client.epics.list(workspace_slug, project_id)
# Retrieve an epicepic=client.epics.retrieve(workspace_slug, project_id, epic_id)# 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)# 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)# 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)The SDK provides comprehensive Pydantic v2 models for all API operations.
BaseQueryParams- Base query parametersPaginatedQueryParams- Cursor-based pagination support (cursor, per_page)WorkItemQueryParams- Work item specific queries (expand, order_by,filters,pql, etc.)RetrieveQueryParams- Retrieve operations (expand, fields, etc.)
WorkItemQueryParams accepts two filter inputs that map to the same backend filter engine:
filters— a structured filter expression (dict). Supports nestedand/or/notgroups and field operators (__in,__gte,__range,__icontains, etc.). The SDK JSON-encodes this into thefilters=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.
Paginated responses follow the pattern Paginated<Resource>Response and include:
results- Array of resource objectstotal_count- Total number of resultsnext_page_number- Next page number (if applicable)prev_page_number- Previous page number (if applicable)
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}")PlaneError- Base exception class with optional status_codeConfigurationError- Invalid client configuration (missing credentials or both auth methods provided)HttpError- HTTP request/response errors with status code and response body
fromplane.clientimportPlaneClientclient=PlaneClient(
base_url="https://api.plane.so",
api_key="your-api-key"
)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
)| Option | Type | Default | Description |
|---|---|---|---|
base_url | str | Required | API base URL |
api_key | str | Optional | API key for authentication |
access_token | str | Optional | Access token for authentication |
timeout | float | tuple[float, float] | 30.0 | Request timeout in seconds |
retry | RetryConfig | None | Retry configuration |
Note: Provide exactly one of api_key or access_token.
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)}")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)}")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}")- Python 3.10+
- requests >= 2.31.0
- pydantic >= 2.4.0
git clone <repository-url>cd plane-python-sdk
pip install -e ".[dev]"# Run all tests
pytest
# Run specific test file
pytest tests/unit/test_work_items.py
# Run with coverage
pytest --cov=plane tests/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-filesplane-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
MIT License - see LICENSE file for details.
For issues and questions:
- GitHub Issues: [Repository Issues]
- Documentation: Plane Documentation
- Email: dev@plane.so
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.