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) from plane import Configuration, ApiClient from plane.apis import WorkItemsApi cfg = 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) from plane.client import PlaneClient from plane.models.query_params import WorkItemQueryParams client = 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-sdk
api_key or access_token for authentication.
import os from plane.client import PlaneClient from plane.errors import ConfigurationError # Using API key client = 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
The SDK also supports OAuth 2.0 authentication for more advanced use cases:
from plane import OAuthClient # Initialize OAuth client oauth_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 URL auth_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 token token = oauth_client.exchange_code( code="authorization_code_from_callback", redirect_uri="https://your-app.com/callback" ) # Step 3: Use the access token client = 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 tokens new_token = oauth_client.refresh_token(token.refresh_token) # Revoke tokens oauth_client.revoke_token(token.access_token)
For detailed OAuth examples, see examples/oauth_example.py.
# List projects in a workspace projects = client.projects.list("my-workspace") # Create a work item from plane.models.work_items import CreateWorkItem work_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 parameters from plane.models.query_params import RetrieveQueryParams work_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 filtering from plane.models.query_params import WorkItemQueryParams work_items = client.work_items.list( workspace_slug="my-workspace", project_id="project-id", params=WorkItemQueryParams(per_page=50, order_by="-created_at") )
client.v2 reaches the v2 surface. v1 resources on the client are unchanged.
The v2 surface is complete: every one of the 90 V2Resource subclasses in the
package (tests/v2/tree_walk.py's all_resource_classes(), the enumeration the
test suite itself sweeps) is on the flat shape described below and reachable
through client.v2. Counting resources means not counting grouping nodes:
wiki and group_sync hold no V2Resource base, path or operations of
their own ā they only group children (wiki.pages, wiki.collections,
group_sync.config) ā and are outside the 90.
Wired directly on client.v2.workspaces: artifacts, assets, audit_logs,
automations, customer_properties, customers, features, initiatives,
invitations, members, permission_schemes, permissions, projects,
releases (.labels, .tags, .comments, .links, .changelog,
.work_items), roles, stickies, teamspaces, views, webhooks (with
.logs), work_item_properties, work_item_relation_definitions,
work_item_templates, work_item_types, and work_items (a distinct,
workspace-wide, list-only resource, not to be confused with the project-scoped
client.v2.workspaces.projects.work_items below), plus the grouping nodes
wiki (.pages, .collections) and group_sync (.config,
.project_mappings, .workspace_mappings). Each takes the workspace slug as
its leading argument, e.g. client.v2.workspaces.roles.list("acme") or
client.v2.workspaces.group_sync.config.retrieve("acme").
The whole project band is wired onto client.v2.workspaces.projects: states,
labels, work_items, cycles, milestones, modules, estimates,
intakes, members, views, features, permissions, work_item_templates,
worklogs, pages, automations, work_item_properties, work_item_types
and workflows.
Two of these are worth calling out because they surprise people:
client.v2.workspaces.roles.list("acme", role_slug="admin")ā the workspace slug is the positional argument; the role's own slug filter isrole_slug, spelled out rather than folded into**filters, because the two would otherwise collide.client.v2.workspaces.group_sync.project_mappingsis workspace-level despite the name ā it takes only the workspace slug, no project.
The bound-locator chain from earlier releases (client.v2.workspace(slug).project(key))
is gone. There are two ways to reach a resource now:
A static tree, reached by plain attribute access. Read it left to right: every
segment that names an actual resource consumes one URL path id (a workspace slug,
a project key, a work item identifier, ...); a segment that only groups children
(wiki) consumes none.
from plane import PlaneClient from plane.models.v2 import CreateState client = PlaneClient(base_url="https://api.plane.so", api_key="...") client.v2.users.me() client.v2.workspaces.retrieve("acme") client.v2.workspaces.projects.states.list("acme", "ENG", fields=["id", "name"]) client.v2.workspaces.projects.work_items.comments.list("acme", "ENG", "ENG-12") client.v2.workspaces.wiki.pages.list("acme") # `wiki` groups, consumes no id client.v2.workspaces.features.retrieve("acme") # singleton: no primary key at all client.v2.workspaces.projects.states.create( "acme", "ENG", CreateState(name="In Review", color="#4ECDC4") )
Path ids are plain positional-or-keyword parameters, so they can be passed by keyword too ā handy when a call's own arguments would otherwise read ambiguously:
client.v2.workspaces.projects.states.list(slug="acme", project="ENG")
A resource with children (projects, work_items, cycles, milestones,
modules, estimates, webhooks, collections, customers, initiatives,
releases, work_item_types, work_item_properties, automations and
workflows ā 18 of the 90 classes, some families having a separate
project-scoped and workspace-scoped resource, each independently navigable)
doesn't just hand back a bare pydantic model from retrieve/list/iterate ā
it hands back a row that carries its own data and already knows where it
lives, so the row's own children are reached with none of the ids repeated:
p = client.v2.workspaces.projects.retrieve("acme", "ENG") p.name p.states.list() # no "acme", "ENG" to repeat item = p.work_items.retrieve("ENG-12") item.comments.list() # same, one level deeper
Membership bridges hang off a loaded row the same way. A fetched cycle reaches
its own work-item membership without repeating "acme", "ENG" or the cycle's
own id:
cycle = client.v2.workspaces.projects.cycles.retrieve("acme", "ENG", "c1") cycle.name cycle.work_items.add(["w1"]) # moves work item "w1" into this cycle
The same navigable shape holds throughout: milestones/modules via their own
.work_items bridge; estimates via .estimate_points (not .points, which
is the row's own inline-expand field); webhooks via .logs, its delivery
log; collections via .members/.pages; customers via .requests,
.property_values, .work_items; initiatives via .labels, .projects,
.work_items; releases via .labels, .tags, .comments, .links,
.changelog, .work_items; work_item_types via .properties;
work_item_properties via .property_options (plus .contexts on the
workspace-scoped resource only); automations via .edges, .nodes,
.activities; and workflows via .states, .transitions. A work item
itself reaches all seven of its own children this way ā comments,
attachments, links, worklogs, activities, relations, dependencies.
list and iterate yield these same navigable rows, not bare pydantic models ā
for project in client.v2.workspaces.projects.iterate("acme"): project.states.list()
works with no extra plumbing. Resources without children (states, labels,
workspaces, wiki.pages, features, releases.labels, intakes, and most
other leaf resources) still return plain pydantic models ā the Loaded mixin
(plane/api/v2/_kernel/loaded.py) is generic and every resource with children
picks it up the same way. tests/v2/test_loaded_navigation.py sweeps every
class that declares a loaded_model and fails if its row's navigation
properties don't match its resource's own children exactly ā see
the four rules the tests enforce below.
Every read field except id is optional at the model level, because ?fields=
and collection deferral can both omit any field the server would otherwise send.
On a Loaded row, reading a field the response didn't carry raises
FieldNotRequested instead of silently returning None ā a None you get back is
a real null, not a sign the data was never fetched:
from plane.api.v2 import FieldNotRequested p = client.v2.workspaces.projects.retrieve("acme", "ENG", fields=["id"]) p.name # raises FieldNotRequested -- "name" was not requested # The same holds with no `fields=` at all: presence follows what the server # actually returned, so a row the collection route deferred fields on still # raises rather than handing back a `None` that looks like real data. row = client.v2.workspaces.projects.list("acme").data[0] row.description # raises FieldNotRequested if the list route omitted it
Field names, order_by values and filter keyword names are all generated
Literal/TypedDict types (from plane/api/v2/_generated/constants.py, produced
from the api_v2 OpenAPI golden), and the package ships a py.typed marker so a
type checker actually reads them. A typo in a filter keyword is a mypy error,
not a runtime surprise:
# mypy rejects this: "not_a_filter" isn't in StatesListFilters client.v2.workspaces.projects.states.list("acme", "ENG", not_a_filter="x")
Navigation off a loaded row is typed the same way, not Any: project.states.list()
resolves to Page[State], a misspelled child (project.states.lst()) or an unknown
keyword on it is still a mypy error, and this holds several hops deep ā a fetched
work item reached through a fetched project still resolves its .comments.list() to
a real model, not a collapsed Any. tests/v2/test_typing.py runs mypy over probe
scripts to prove it, rather than trusting it by inspection.
Five properties of the surface are each enforced by a sweep in tests/v2/, over
every one of the 90 resource classes (tests/v2/tree_walk.py's
all_resource_classes()) rather than a hand-picked subset ā so a newly added
resource is covered the moment it exists, with nothing to remember to add it to:
- Path-id naming (
tests/v2/test_path_id_naming.py). Every path-id parameter is named after the resource it identifies, singular, with no_idsuffix āslug,project,work_item,state,label,page,comment,release, and so on, the same name whether it's a method's own primary key or an ancestor's. This isn't cosmetic:Owned(the mechanism behind loaded-row navigation) matches a child method's leading parameter names against its parent's literally, so a resource that suffixed its own id would silently break navigation from its parent. URL templates and model field names keep their own golden-derived names ({project_id},WorkItem.state_id) ā this rule is about method parameters only. expandexposure (tests/v2/test_expand_coverage.py). Wherever the api_v2 OpenAPI golden declares an operation can expand a relation, the SDK method exposes anexpandparameter for it. A method that just omits the parameter makes that capability unreachable from the SDK with no error to notice it by ā which is exactly how eleven methods shipped without it before this sweep existed.fieldsexposure (tests/v2/test_fields_coverage.py). The same shape for?fields=: wherever the golden declares it for an operation, the method exposes it. The one deliberate exception is a response that cannot be re-fetched ā a secret shown once (Webhooks.regenerate) or a presigned-upload envelope whose extra data exists only in that one reply (WorkItemAttachments.create,WorkspaceAssets.create,UserAssets.create) ā where projecting fields could silently and irrecoverably drop data the caller has no second chance at. Those are named, with their reason, in the test's ownONE_TIME_RESPONSESset, and the reason is repeated in the method's docstring so the next reader doesn't "fix" the omission back.- Pagination exposure (
tests/v2/test_pagination_coverage.py). The same shape again, for the parameters that pick the envelope rather than shape the rows:per_page,offset,paginateandcount.listexposes every one its operation declares;iterateexposesper_pageandpaginate(page size and envelope choice are the caller's) but notoffsetorcount, which belong to the auto-pager's own walk. This sweep is the newest, and it was added because its absence was expensive:paginateandcountwere reserved by the constants generator ā kept out of every*FiltersTypedDict on the grounds that each belonged on the method as an explicit parameter ā and then never added to a single one of the 68 list methods. Nothing could see it, becauseFIELDSandEXPANDwere the only golden tables the generator emitted. The cost:client.v2.workspaces.audit_logscould not be listed at all (the server refuses the offset envelope there), theCursorPagebranch ofparse_pagewas unreachable from any public method, andcount=falsewas unsendable while the kernel's own_find_onehad been sending it all along. - Loaded-row navigation completeness (
tests/v2/test_loaded_navigation.py). For every resource that declares aloaded_model, itsLoadedrow type's navigation properties must be exactly the child resources the resource class itself attaches ā no more, no fewer, and each must wrap its own child rather than a copy-pasted sibling's. This is what closes the gap a name-only comparison would miss: a resource can attach fifteen children while its row exposes three, with every other test still green, unless something checks the two sides against each other.
releases.labels sits on workspaces too (client.v2.workspaces.releases.labels)
and, being both a catalog and a membership bridge, additionally exposes add/
remove to attach/detach existing labels on a specific release. Both take every
path id the bridge's own URL needs ā the workspace slug, then the release id ā
ahead of 1..100 label ids to add/remove:
client.v2.workspaces.releases.labels.add("acme", release.id, [label.id]) client.v2.workspaces.releases.labels.remove("acme", release.id, [label.id])
An empty list, or more than 100 ids, raises ValueError before any request is
sent.
workspaces.permissions is a singleton like features, reached with just the
slug and no primary key:
client.v2.workspaces.permissions.me("acme")
group_sync groups three resources under one namespace without consuming a path
id itself ā each child still takes its own leading slug:
client.v2.workspaces.group_sync.config.retrieve("acme") client.v2.workspaces.group_sync.project_mappings.list("acme") client.v2.workspaces.group_sync.workspace_mappings.list("acme")
Errors from client.v2 calls raise PlaneAPIError (RFC 9457 problem detail ā
.status, .type, .code, .detail, .errors), and find_by_name raises
NoMatchFound or MultipleMatchesFound when it can't resolve to exactly one row.
All three, plus FieldError (the shape of one entry in .errors), are re-exported
from both plane.api.v2 and the top-level plane package:
from plane.api.v2 import MultipleMatchesFound, NoMatchFound, PlaneAPIError # or, equivalently: # from plane import MultipleMatchesFound, NoMatchFound, PlaneAPIError try: todo = client.v2.workspaces.projects.states.find_by_name("acme", "ENG", "Todo") except NoMatchFound: ... except MultipleMatchesFound: ... try: client.v2.workspaces.projects.states.create("acme", "ENG", CreateState(name="", color="#fff")) except PlaneAPIError as e: print(e.status, e.code, e.detail)
The SDK is organized around a central PlaneClient that provides access to various resource classes:
from plane.client import PlaneClient client = PlaneClient( base_url="https://api.plane.so", api_key="your-api-key" ) # Access different resources client.users # User management client.workspaces # Workspace operations client.projects # Project management client.work_items # Work item operations client.cycles # Cycle management client.modules # Module management client.labels # Label management client.states # State/workflow management client.work_item_types # Work item type management client.work_item_properties # Custom properties client.epics # Epic management client.intake # Intake management client.pages # Page management client.customers # Customer management client.teamspaces # Teamspace management client.stickies # Sticky management client.initiatives # Initiative management
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
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 user me = client.users.get_me() # Retrieve a specific user user = client.users.retrieve(user_id) # List all users users = client.users.list()
# Get workspace members members = client.workspaces.get_members(workspace_slug) # Filter members (all filters combine with AND; role_slug is exact, text fields # match case-insensitive contains) from plane.models.query_params import MemberQueryParams, MemberListQueryParams admins = 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 False paginated_members = client.workspaces.get_members_lite( workspace_slug, params=MemberListQueryParams(per_page=1000), ) all_members = list(paginated_members.results) while paginated_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) for role in distribution.roles: print(role.slug, role.membership_count, role.distinct_member_count)
# List all role definitions (workspace + project), paginated envelope page = client.roles.list(workspace_slug) for role in page.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 id role = 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 project from plane.models.projects import CreateProject project = client.projects.create( workspace_slug="my-workspace", data=CreateProject( name="My Project", identifier="MP", description="Project description" ) ) # List projects projects = client.projects.list(workspace_slug="my-workspace") # Retrieve a project project = client.projects.retrieve(workspace_slug, project_id) # Update a project from plane.models.projects import UpdateProject project = client.projects.update( workspace_slug, project_id, data=UpdateProject(name="Updated Name") ) # Delete a project client.projects.delete(workspace_slug, project_id) # Get worklog summary worklog_summary = client.projects.get_worklog_summary(workspace_slug, project_id) # Get project members members = client.projects.get_members(workspace_slug, project_id) # Filter project members (same filters as workspace members) from plane.models.query_params import MemberQueryParams, MemberListQueryParams members = client.projects.get_members( workspace_slug, project_id, params=MemberQueryParams(display_name="ana", is_bot=False), ) # Paginated "lite" list members = 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. from plane.models.query_params import ProjectLiteListQueryParams lite = client.projects.list_lite( workspace_slug, params=ProjectLiteListQueryParams(per_page=1000, order_by="-created_at"), ) for p in lite.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 item from plane.models.work_items import CreateWorkItem work_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 item from plane.models.query_params import RetrieveQueryParams work_item = client.work_items.retrieve( workspace_slug, project_id, work_item_id, params=RetrieveQueryParams(expand="assignees,labels,state") ) # List work items from plane.models.query_params import WorkItemQueryParams work_items = client.work_items.list( workspace_slug, project_id, params=WorkItemQueryParams(per_page=50, order_by="-created_at") ) # Update a work item from plane.models.work_items import UpdateWorkItem work_item = client.work_items.update( workspace_slug, project_id, work_item_id, data=UpdateWorkItem(priority="low", state_id="new-state-id") ) # Delete a work item client.work_items.delete(workspace_slug, project_id, work_item_id) # Search work items results = client.work_items.search( workspace_slug, project_id, query="bug fix" )
# Comments comments = 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) # Attachments attachments = 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) # Links links = 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) # Relations relations = 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) # Activities activities = client.work_items.activities.list(workspace_slug, project_id, work_item_id) # Work Logs work_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 cycle from plane.models.cycles import CreateCycle cycle = 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 cycles cycles = 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). from plane.models.query_params import CycleListQueryParams upcoming = client.cycles.list( workspace_slug, project_id, params=CycleListQueryParams(status="upcoming"), ) for c in upcoming.results: # paginated envelope print(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"), ) for c in current: # plain list[Cycle] print(c.name) # Retrieve a cycle cycle = client.cycles.retrieve(workspace_slug, project_id, cycle_id) # Update a cycle from plane.models.cycles import UpdateCycle cycle = client.cycles.update( workspace_slug, project_id, cycle_id, data=UpdateCycle(name="Updated Sprint") ) # Delete a cycle client.cycles.delete(workspace_slug, project_id, cycle_id) # List archived cycles archived = 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". from plane.models.query_params import CycleLiteListQueryParams lite = client.cycles.list_lite( workspace_slug, project_id, params=CycleLiteListQueryParams(status="current", per_page=1000), ) for c in lite.results: print(c.name) # Add work items to cycle from plane.models.cycles import AddWorkItemsToCycleRequest client.cycles.add_work_items( workspace_slug, project_id, cycle_id, data=AddWorkItemsToCycleRequest(issues=[work_item_id]) ) # Remove work item from cycle client.cycles.remove_work_item(workspace_slug, project_id, cycle_id, work_item_id) # List work items in cycle cycle_items = client.cycles.list_work_items(workspace_slug, project_id, cycle_id) # Transfer work items between cycles from plane.models.cycles import TransferCycleWorkItemsRequest client.cycles.transfer_work_items( workspace_slug, project_id, cycle_id, data=TransferCycleWorkItemsRequest(new_cycle_id="other-cycle-id") ) # Archive/unarchive cycles client.cycles.archive(workspace_slug, project_id, cycle_id) client.cycles.unarchive(workspace_slug, project_id, cycle_id)
# Create a module from plane.models.modules import CreateModule module = client.modules.create( workspace_slug, project_id, data=CreateModule(name="Auth Module") ) # List modules modules = client.modules.list(workspace_slug, project_id) # Retrieve a module module = client.modules.retrieve(workspace_slug, project_id, module_id) # Update a module from plane.models.modules import UpdateModule module = client.modules.update( workspace_slug, project_id, module_id, data=UpdateModule(name="Updated Module") ) # Delete a module client.modules.delete(workspace_slug, project_id, module_id) # List archived modules archived = client.modules.list_archived(workspace_slug, project_id) # Paginated "lite" module list (full module fields minus issue-count metrics) from plane.models.query_params import LiteListQueryParams lite = client.modules.list_lite( workspace_slug, project_id, params=LiteListQueryParams(per_page=1000, order_by="-created_at"), ) for m in lite.results: print(m.name) # Add work items to module from plane.models.modules import AddWorkItemsToModuleRequest client.modules.add_work_items( workspace_slug, project_id, module_id, data=AddWorkItemsToModuleRequest(issues=[work_item_id]) ) # Remove work item from module client.modules.remove_work_item(workspace_slug, project_id, module_id, work_item_id) # List work items in module module_items = client.modules.list_work_items(workspace_slug, project_id, module_id) # Archive/unarchive modules client.modules.archive(workspace_slug, project_id, module_id) client.modules.unarchive(workspace_slug, project_id, module_id)
# Create a state from plane.models.states import CreateState state = client.states.create( workspace_slug, project_id, data=CreateState( name="In Progress", color="#3b82f6", group="started" ) ) # List states states = client.states.list(workspace_slug, project_id) # Retrieve a state state = client.states.retrieve(workspace_slug, project_id, state_id) # Update a state from plane.models.states import UpdateState state = client.states.update( workspace_slug, project_id, state_id, data=UpdateState(name="Updated Status") ) # Delete a state client.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 only from plane.models.states import CreateWorkspaceState state = client.workspace_states.create( workspace_slug, data=CreateWorkspaceState(name="In Review", color="#3b82f6", group="started"), ) # Retrieve / update / delete state = client.workspace_states.retrieve(workspace_slug, state_id) from plane.models.states import UpdateWorkspaceState state = client.workspace_states.update( workspace_slug, state_id, data=UpdateWorkspaceState(color="#22c55e") ) client.workspace_states.delete(workspace_slug, state_id)
# Create a label from plane.models.labels import CreateLabel label = client.labels.create( workspace_slug, project_id, data=CreateLabel(name="Bug", color="#ef4444") ) # List labels labels = client.labels.list(workspace_slug, project_id) # Retrieve a label label = client.labels.retrieve(workspace_slug, project_id, label_id) # Update a label from plane.models.labels import UpdateLabel label = client.labels.update( workspace_slug, project_id, label_id, data=UpdateLabel(name="Updated Label") ) # Delete a label client.labels.delete(workspace_slug, project_id, label_id)
# Create a work item type from plane.models.work_item_types import CreateWorkItemType wit = client.work_item_types.create( workspace_slug, project_id, data=CreateWorkItemType(name="Story") ) # List work item types types = client.work_item_types.list(workspace_slug, project_id) # Retrieve a work item type wit = client.work_item_types.retrieve(workspace_slug, project_id, type_id) # Update a work item type from plane.models.work_item_types import UpdateWorkItemType wit = client.work_item_types.update( workspace_slug, project_id, type_id, data=UpdateWorkItemType(name="Updated Type") ) # Delete a work item type client.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 workflows workflows = client.workspace_workflows.list(workspace_slug) # Create a workflow draft, then configure its chain from catalog states from plane.models.states import CreateWorkspaceState from plane.models.workspace_workflows import ( 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) # Transitions transition = 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 log workflow = 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 governance governance = client.work_item_type_governance.retrieve(workspace_slug, type_id) from plane.models.work_item_type_governance import UpdateTypeGovernance governance = client.work_item_type_governance.update( workspace_slug, type_id, data=UpdateTypeGovernance(mode="constrained", workflow_ids=[workflow_id]), ) # Dry-run the impact first from plane.models.work_item_type_governance import TypeGovernancePreviewRequest preview = client.work_item_type_governance.preview( workspace_slug, type_id, data=TypeGovernancePreviewRequest(mode="required", required_workflow_id=workflow_id), ) # Per-project pins pins = client.work_item_type_governance.pins.list(workspace_slug, type_id) # Project-side view: each type's effective workflow, and the project's pick entries = client.work_item_type_governance.project_workflows.list(workspace_slug, project_id) from plane.models.work_item_type_governance import SetProjectWorkflowPick client.work_item_type_governance.project_workflows.update_pick( workspace_slug, project_id, type_id, data=SetProjectWorkflowPick(workflow_id=workflow_id) )
# Create a property from plane.models.work_item_properties import CreateWorkItemProperty prop = client.work_item_properties.create( workspace_slug, project_id, work_item_type_id, data=CreateWorkItemProperty(name="Severity") ) # List properties properties = client.work_item_properties.list(workspace_slug, project_id, work_item_type_id) # Retrieve a property prop = client.work_item_properties.retrieve(workspace_slug, project_id, work_item_type_id, property_id) # Update a property from plane.models.work_item_properties import UpdateWorkItemProperty prop = client.work_item_properties.update( workspace_slug, project_id, work_item_type_id, property_id, data=UpdateWorkItemProperty(name="Updated Property") ) # Delete a property client.work_item_properties.delete(workspace_slug, project_id, work_item_type_id, property_id)
# List epics epics = client.epics.list(workspace_slug, project_id) # Retrieve an epic epic = client.epics.retrieve(workspace_slug, project_id, epic_id)
# Create intake issue from plane.models.intake import CreateIntake intake = client.intake.create( workspace_slug, project_id, data=CreateIntake(name="Customer request") ) # List intake issues intake_items = client.intake.list(workspace_slug, project_id) # Retrieve intake issue intake = client.intake.retrieve(workspace_slug, project_id, intake_id) # Update intake issue from plane.models.intake import UpdateIntake intake = client.intake.update( workspace_slug, project_id, intake_id, data=UpdateIntake(status="completed") ) # Delete intake issue client.intake.delete(workspace_slug, project_id, intake_id)
# List workspace pages pages = client.pages.list_workspace_pages(workspace_slug) # List project pages pages = client.pages.list_project_pages(workspace_slug, project_id) # Retrieve a workspace page page = client.pages.retrieve_workspace_page(workspace_slug, page_id) # Retrieve a project page page = client.pages.retrieve_project_page(workspace_slug, project_id, page_id)
# List customers customers = client.customers.list(workspace_slug) # Create a customer from plane.models.customers import CreateCustomer customer = client.customers.create( workspace_slug, data=CreateCustomer(name="Acme Inc") ) # Retrieve a customer customer = client.customers.retrieve(workspace_slug, customer_id) # Update a customer from plane.models.customers import UpdateCustomer customer = client.customers.update( workspace_slug, customer_id, data=UpdateCustomer(name="Updated Name") ) # Delete a customer client.customers.delete(workspace_slug, customer_id) # Customer properties properties = client.customers.properties.list(workspace_slug, customer_id) property = client.customers.properties.create(workspace_slug, customer_id, data) # Customer requests requests = 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.
from plane.models.query_params import WorkItemQueryParams # Project-scoped, structured filters client.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, PQL client.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-side client.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:
from plane.errors import PlaneError, ConfigurationError, HttpError # Configuration errors try: client = PlaneClient(base_url="https://api.plane.so") # Missing both api_key and access_token except ConfigurationError as e: print(f"Configuration error: {e}") # HTTP errors try: work_item = client.work_items.retrieve("workspace", "project", "invalid-id") except HttpError as e: 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
from plane.client import PlaneClient client = PlaneClient( base_url="https://api.plane.so", api_key="your-api-key" )
from plane.config import Configuration, RetryConfig from plane.client import PlaneClient # Custom retry configuration retry_config = RetryConfig( total=5, # Number of retries backoff_factor=0.5, # Backoff multiplier status_forcelist=(429, 500, 502, 503, 504) # Retry on these status codes ) # Create client with custom config client = PlaneClient( base_url="https://api.plane.so", api_key="your-api-key", timeout=60.0, # Request timeout in seconds retry=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.
from plane.client import PlaneClient from plane.models.projects import CreateProject from plane.models.work_items import CreateWorkItem from plane.models.states import CreateState from plane.models.labels import CreateLabel from plane.models.query_params import WorkItemQueryParams client = PlaneClient( base_url="https://api.plane.so", api_key="your-api-key" ) # Create a project project = 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 state state = client.states.create( workspace_slug="my-workspace", project_id=project.id, data=CreateState( name="In Progress", color="#3b82f6", group="started" ) ) # Create a label label = client.labels.create( workspace_slug="my-workspace", project_id=project.id, data=CreateLabel(name="Bug", color="#ef4444") ) # Create a work item work_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 filters work_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)}")
from plane.models.cycles import CreateCycle, AddWorkItemsToCycleRequest # Create a cycle cycle = 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 cycle client.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 items cycle_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)}")
from plane.models.work_items import CreateWorkItemComment # Add a comment comment = 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 comments comments = 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 attachment attachment = 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-files
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
MIT License - see LICENSE file for details.
For issues and questions:
- GitHub Issues: [Repository Issues]
- Documentation: Plane Documentation
- Email: [email protected]
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.