This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Plane Python SDK (plane-sdk on PyPI, v0.3.0) — a synchronous, type-annotated Python client for the Plane API. Built on requests + pydantic v2, targeting Python 3.10+.
# Install for development
pip install -e .
pip install -r requirements.txt
# Run all unit tests (requires env vars, see below)
pytest tests/unit/
# Run a specific test file or test
pytest tests/unit/test_projects.py
pytest tests/unit/test_projects.py::TestProjectsAPICRUD::test_create_project
# Integration/script tests (excluded by default via addopts)
pytest tests/scripts/ --override-ini="addopts="
# Formatting & linting
black plane tests
ruff check plane tests
ruff check --fix plane tests
# Type checking
mypy planeTests make real HTTP requests (no mocking). Set these before running:
PLANE_BASE_URL— API base URLPLANE_API_KEYorPLANE_ACCESS_TOKEN— authentication (exactly one)WORKSPACE_SLUG— test workspaceAGENT_SLUG— (optional) needed only for agent run tests
PlaneClient is the single entry point. It holds a Configuration and exposes resource objects as attributes:
PlaneClient
├── .projects → Projects(BaseResource)
├── .work_items → WorkItems(BaseResource)
│ ├── .comments
│ ├── .attachments
│ ├── .links
│ └── ...sub-resources
├── .cycles → Cycles(BaseResource)
└── ...15+ resources
plane/api/— Resource classes. Every resource extendsBaseResourcewhich handles HTTP methods, auth headers, URL building (/api/v1/...), retry viaurllib3.Retry, and response parsing.plane/models/— Pydantic v2 models. Three kinds per resource:- Response models (e.g.
Project):extra="allow"for forward compatibility with new API fields. - Request DTOs (e.g.
CreateProject,UpdateProject):extra="ignore"to be strict about inputs. - Query param models (e.g.
PaginatedQueryParams):extra="ignore".
- Response models (e.g.
plane/client/—PlaneClient(API key / access token auth) andOAuthClient(OAuth 2.0 flows).plane/errors/—PlaneError→HttpError,ConfigurationError.plane/config.py—ConfigurationandRetryConfigdataclasses.plane/api/v2/— the v2 surface. The chain is the only public form:client.v2.workspace(slug)(Workspace,plane/api/v2/workspace.py) and.project(project)(Project,plane/api/v2/project.py) are zero-I/O locators that bindslug/project_idonce; every v2 resource hangs off one of them as a plain attribute (.wikionWorkspaceis itself a small locator,Wikiinplane/api/v2/wiki.py, holding.pages/.collections).client.v2.users/.user_assetsare the only resources kept directly onV2Namespace(the 6 operations with no workspace in their path)._kernel/holds the shared machinery:V2Resource.__init__(transport, **scope)stores the bound scope, and_collection_url/_detail_urlmerge it with any explicitly passed path params (explicit wins) — a resource constructed with no scope (most offline tests do this) behaves exactly as if every path param were passed per call, so a resource's methods work identically whether or not it was reached through the chain. No public v2 method takesworkspace_slug/projectparameters — the locator supplies both; leaf ids (work_item_id,release_id, ...) stay as the first positional argument._generated/constants.pyis produced byscripts/generate_v2_constants.pyfrom the api_v2 OpenAPI golden and must never be hand-edited.- Bridges. Membership between two resources (
.../cycles/{id}/work-items/,.../releases/{id}/labels/,.../collections/{id}/members/, ...) is never amanage_*(add=, remove=)method. It is a sub-resource (proj.cycles.work_items,ws.initiatives.projects; on a catalog resource such asws.releases.labelsthe verbs sit next to the CRUD) exposing exactlyadd(parent_id, ids) -> list[str]andremove(parent_id, ids) -> list[str], both delegating toV2Resource._bridge(key=, ids=, **path_params). The kernel POSTs{"add": [...]}or{"remove": [...]}only, rejects 0 or >100 ids withValueErrorbefore the request, and returns the response'sadded/removedlist. A class whose ownpathis not the bridge URL setsbridge_path. The bridge class declares the golden's single manage operationId under the"bridge"key ofoperations. The*Manage*request/response models stay inplane/models/v2/*as the bridge'smodel, but are not exported fromplane.models.v2. - Bridge verbs copy the web app CTA. Properties on a work item type:
link/unlink(unlink deletes the property's values on every work item of the type). Members of anything else:add/remove.workflows.states.attachis a different bridge (POST{state_ids}) and keeps its name. - Lookups.
find_by_<key>is server-side via_find_oneonly where the golden list op has that filter (roles.find_by_slug,estimates.points.find_by_key,find_by_nameon properties/options/contexts); propertynameis the machine key, not thedisplay_namelabel.
- Bridges. Membership between two resources (
plane/models/v2/— v2 pydantic models. Read models mark every field exceptidoptional, because?fields=and collection deferral can omit any of them.
Resources with children (work_items, customers, initiatives, teamspaces) instantiate sub-resource objects in __init__:
class WorkItems(BaseResource):
def __init__(self, config):
super().__init__(config, "/workspaces/")
self.comments = WorkItemComments(config)
self.attachments = WorkItemAttachments(config)All API endpoints end with a trailing /. URLs are built as {base_path}/api/v1{resource_base_path}/{endpoint}/.
- Line length: 100 (Black + Ruff)
- Use
X | NonenotOptional[X]; uselist[str]notList[str](Python 3.10+ builtins) - Import abstract types from
collections.abc(e.g.Mapping,Iterable) - Ruff rules: E, F, I (isort), UP (pyupgrade), B (bugbear)
- Never use "Issue" in endpoint or parameter names — always use "Work Item"
- Auth is mutually exclusive:
api_keyXORaccess_token(raisesConfigurationErrorif both/neither) - Resource methods accept Pydantic DTOs, serialize with
model_dump(exclude_none=True), and validate responses withModel.model_validate() - All resources follow CRUD verbs:
create,retrieve,update,delete,list