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-sdkapi_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 providedThe 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 deeperMembership 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 cycleThe 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 itField 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 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 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-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.