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 planeScoping a migration plan's gates to its own diff: when a task gate needs "the files
this plan touched" (e.g. a black --check/mypy pass limited to one v2 migration plan's
work), anchor the git diff to the plan's own base commit — the parent of its first
commit — not to HEAD~N. A fixed commit count silently under-counts as soon as a plan
gains more commits than the window (verified during the v2 workspace-resources plan:
a HEAD~5 window missed 4 already-migrated files that landed earlier in the same plan).
Anchoring to the base commit is stable regardless of how many commits the plan ends up
taking.
Tests 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 (client.v2). Migration complete: all 90V2Resourcesubclasses in the package (tests/v2/tree_walk.py'sall_resource_classes()) are on the flat shape and swept by the rule tests.tests/v2/tree_walk.py'sUNMIGRATED_RESOURCES— the opt-out list the sweeps excluded a class by naming it in — is nowfrozenset(); nothing is opted out any more, andtest_path_id_naming.pystill enforces that it can only shrink, never grow, so it cannot silently regain a member. Count resources, not grouping nodes:wikiandgroup_synchold noV2Resourcebase,pathoroperationsof their own — they only group children (wiki.pages,wiki.collections,group_sync.config) — so neither is in the 90.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_typesandworkflows— nineteen children, and a fetched project reaches every one of them that is itself navigable (project.cycles.list(),project.permissions.me()). Nineteen of the 90 classes are navigable —workspaces,projects,work_items,cycles,milestones,modules,estimates,webhooks,collections,customers,initiatives,releases,work_item_types,work_item_properties,automationsandworkflows(three of those — automations, work item types and work item properties — each have a separate project-scoped and workspace-scoped resource class, each independently navigable, which is where 16 families become 19 classes) — a fetched row reaches its child with no ids repeated.workspacesis the root of that set and the last to join it:client.v2.workspaces.retrieve("acme")answers aLoadedWorkspacereaching all 25 workspace-scoped families (workspace.projects.list()), and its children address it byslug, never the UUIDid, which that path segment does not accept.estimates' child isestimate_points, notpoints—Estimate.pointsis itself an API field, returned inline byexpand=["points"].webhooksis workspace-scoped, not part of the project band, reached via.logs. A fetched work item reaches all seven of its children —comments,attachments,links,worklogs,activities,relations,dependencies.client.v2.workspaces.roles.list("acme", role_slug="admin")is worth flagging: the workspace slug is the positional argument, while the role's own slug filter is spelledrole_slugbecause it would otherwise collide with it. The bound-locator chain (client.v2.workspace(slug).project(project)) is gone. There are two ways into a resource:-
The flat path. A static tree reached by plain attribute access, e.g.
client.v2.workspaces.projects.states.list("acme", "ENG"),client.v2.workspaces.projects.work_items.comments.list("acme", "ENG", "ENG-12"). Read it left to right: every segment that names an actual resource consumes one URL path id, in order; a segment that only groups children (.wikionWorkspaces,plane/api/v2/wiki_node.py, holding.pagesand.collections, both real resources — neither is a placeholder) consumes none. Path ids are positional-or-keyword (states.list(slug="acme", project="ENG")works).client.v2.users/.user_assetsare the only resources kept directly onV2Namespace(the 6 operations with no workspace in their path). A singleton with no primary key of its own (workspaces.features,plane/api/v2/features.py) goes through the kernel's_retrieve_singleton/_update_singletonpair instead of_retrieve/_update, which both require apkto append. -
Path ids — the naming rule (one rule, no exceptions). A path-id parameter is named after the resource it identifies, singular, with no
_idsuffix. Soslug(the workspace),project,work_item,state,label,page,comment,release— the same name whether it is the method's own primary key (work_items.retrieve(slug, project, work_item)) or an ancestor's (work_items.comments.list(slug, project, work_item)). This is not cosmetic:Ownedcompares a child method's leading parameter names against the parent'sloaded_namesliterally and refuses the call on a mismatch, so a resource that suffixes its own pk breaks navigation from its parent. Two things keep their golden-derived names and are not covered by this rule: the URL templates (path = ".../projects/{project_id}/work-items/{work_item_id}/comments/") and model field names (WorkItem.state_id).tests/v2/test_path_id_naming.pyenforces it across every resource. The set under test is enumerated, not hand-picked:tests/v2/tree_walk.py'sall_resource_classes()returns everyV2Resourcesubclass in the package, minusUNMIGRATED_RESOURCES— an explicit opt-out list, not a heuristic selection (wired-onto-the-tree, or "listalready looks flat-shaped") the way earlier rounds picked members.UNMIGRATED_RESOURCESisfrozenset()now — every class is swept — andtest_path_id_naming.pystill enforces that it can only shrink (never regain a name once removed), so a regression can't quietly opt a class back out. "Flat-shaped" is judged over every public method (flat_shaped_resource_classes()), not overlistalone — a bridge, a singleton or a dict-shaped resource has nolist, so a heuristic keyed onlistcould never have caught one of those being migrated while staying opted out. A hand-picked selection is what let a whole batch of violations ship green once — never reintroduce one. -
Loaded rows. A resource with children (
projects,work_items,cycles,milestones,modules,estimates,webhooks,collections,customers,initiatives,releases,work_item_types,work_item_properties,automations,workflows,workspaces— 19 of the 90 classes) returns aLoadedrow fromretrieve/list/iterate, not a bare pydantic model: it carries its own data and reaches its own children with none of the ids repeated (project.states.list(),work_item.comments.list(),cycle.work_items.add(["w1"])).Loaded.build(row, ids, fields)(_kernel/loaded.py) is the mixin; reading a field the row does not carry raisesFieldNotRequestedinstead of reading asNone— aNoneyou get back is a real null. Presence is computed from the response (row.model_fields_set), narrowed byfields=when the caller passed one — never from the request alone, because the API defers fields on collection reads even when nofields=is in play.Owned(resource, ids, names)is the other half: it prepends a parent's already-bound ids ahead of a child method's own arguments, and raisesTypeErrorup front if the child's leading parameters aren't ordered the waynamesexpects, rather than silently sending values into the wrong parameter.A loaded row also keeps the forward-compatibility guarantee the read models are
extra="allow"for:Loaded.__getattr__shadowsBaseModel.__getattr__, where pydantic serves__pydantic_extra__, so it falls through to it before raising. Without that fall-through a field the server sends and the model does not declare was readable on a plain row andAttributeErroron a loaded one — whilemodel_dump()and_presentboth still reported it.A loaded row must reach every child its resource attaches. The two sides used to be unrelated:
test_tree.pycomparedPROJECT_TREE_ATTACHMENTSagainstvars(Projects(...)), and nothing compared either toLoadedProject, which is howProjectscame to attach fifteen children while its rows exposed three.tests/v2/test_loaded_navigation.pysweeps it now — for every class declaring aloaded_model, the navigation properties on itsLoadedtype must be exactly the child resources the class attaches, and each must wrap its own child. The one allowed divergence is a name collision with a real API field, written down in that file'sNAVIGATION_ALIASES(Estimate.points→estimate_points).And having children must itself mean declaring a
loaded_model. Those two checks select the classes that declare one, so a resource with children and noloaded_modelhad nothing to compare and passed by never being looked at — which is howWorkspacescame to attach 24 children and answer a bareWorkspace,workspace.projectsraisingAttributeErroron the design's navigable row #1 with every sweep green. The same shape as the path-id rule breaking across 16 classes: a rule enforced over an opportunistic subset holds only for the members that opted in. So the same file enumerates instead —test_every_resource_with_children_declares_a_loaded_modelruns over every class in the package, andtest_the_child_bearing_sweep_bitesruns it against a synthetic class built to violate it, so the failure is a thing that has been seen rather than assumed. A child that is not really a per-row child — a workspace-level catalog hung off a family — is moved to the scope it belongs to, not exempted.A navigable resource mixes in
LoadsNavigableRows[LoadedX], declaresloaded_model/loaded_names, overrides_row_idonly where a child URL uses something other thanid(projects useidentifier), and then every method that answers with a row returnsself._load(row, *parent_ids, fields=fields)— includingfind_by_nameand verb actions likearchive. Any method returning a row of a navigable type returns the loaded form; mixing plain and loaded returns on one class silently drops navigation.tests/v2/test_iterate_parity.pyguards the pair that actually broke this way once: for every navigable class,iteratemust yield the typelistreturns, with the parent ids threaded on page 2 as well as page 1 — a raw row fromiteratehas no navigation, and the divergence shows up only at the call site.One class is exempt, structurally:
WorkspaceWorkItems(the workspace-wide listing,plane/api/v2/work_items/workspace.py). Its rows are plainWorkItems. ALoadedWorkItem's children need("slug", "project", "work_item"), and this route's URL band has no project segment to bind one from —Ownedwould refuse the call rather than prepend two ids into three parameters. Reading the id off the row'sproject_idfield instead is the wrong fix:?fields=and collection deferral can omit it, so navigation would depend on the caller's projection. It is the only such class, it says so in its own docstring, and navigation for those rows goes through the project-scoped band (workspace.projects.retrieve("ENG").work_items…), which has the full URL. A second class landing here is a design question, not a precedent to copy.Navigation must be typed, not
Any.Owned.__getattr__andLoaded.__getattr__are hidden behindif not TYPE_CHECKING, so eachLoadedsubclass declares anif TYPE_CHECKINGview class per child built from the kernel'sbind1/bind2/bind3helpers — onestaticmethod(bindN(Child.method))line per method,Nbeing how many ids the parent binds.Concatenatestrips exactly those leading parameters, soproject.states.list()types asPage[State], unknown keywords are rejected and misspelled methods are errors.tests/v2/test_typing.pyruns mypy to prove it._loaded/holds one module perLoadedsubclass —project.py,work_item.py,cycle.py,milestone.py,module.py,estimate.py,webhook.py,collection.py,customer.py,initiative.py,release.py,work_item_type.py,work_item_property.py,automation.py,workflow.py,workspace.py; copy whichever is closest in shape (single bridge-only child vs. several plain-CRUD children).workspace.pyis thebind1exemplar and the widest, at 25 children; the two grouping nodes (wiki,group_sync) are deliberately not among them — neither holds aV2Resourcebase, so neither is a child a row can bind, and they are reached from the namespace instead. -
_kernel/holds the shared machinery beyondloaded.py:V2Resource.__init__(transport)takes no bound scope any more —_collection_url/_detail_urlbuild straight from whatever path params a call passes — so a resource's methods work identically whether reached through the flat tree or constructed directly (most offline tests do the latter). A path id the call never supplied raisesMissingPathId(exported fromplane.api.v2) naming the resource, method, template and missing id, not a bareKeyError. No public v2 method takesworkspace_slug/projectparameters as such; the path segment's own leading positional-or-keyword parameters carry them, in path order. A custom verb whose response envelope is not a row of the resource's ownmodel(artifacts.publish,invitations.bulk,members.remove,work_items.retrieve_by_identifier) goes through_custom_request/_custom_action/_custom_action_list— never a hand-rolledtransport.request, which silently skips_query'sfields/expandvalidation. They take the responsemodelexplicitly and build either URL shape: withpkthe verb hangs off a row ({detail}/{action}/,_action's URL), without one it goes throughurl_forand itsextra_pathsoverride. Where the response is a row ofmodel, keep using_action/_void_action._generated/constants.pyis produced byscripts/generate_v2_constants.pyfrom the api_v2 OpenAPI golden and must never be hand-edited — it is also what makes field names,order_byvalues and filter keyword names real generatedLiteral/TypedDicttypes instead of loose strings, which is why the package ships apy.typedmarker (tests/v2/test_typing.pyproves a type checker actually rejects an unknown filter keyword). Every option the golden offers an operation must be reachable on the method:tests/v2/test_expand_coverage.pysweeps every resource class against the golden'sEXPANDtable and fails on any method that omits anexpandthe API accepts (deleteexcepted — 204, no body to shape). The same goes forfields: it is exposed wherever the golden declares?fields=for an operation, except an operation whose response body is one-time and unrecoverable — a secret shown once (Webhooks.regenerate), or an envelope richer than the golden documents whose extra data only exists in that one reply (WorkItemAttachments.create) — where a projection could silently and irrecoverably drop data the caller cannot get back. Those omissions must name the one-time-response reason in the method's own docstring, or the next reader "fixes" them back. That rule is a sweep too now, not just prose:tests/v2/test_fields_coverage.pyis theFIELDStwin of theexpandsweep, and the exceptions are enumerated in itsONE_TIME_RESPONSESwith the reason —Webhooks.regenerateplus the three presigned-upload creates (WorkItemAttachments.create,WorkspaceAssets.create,UserAssets.create, whoseupload_dataexists only in that one reply). Where a resource spells filters out one by one instead of**filters: Unpack[...](Roles, because the golden's?slug=collides with the path idslug), pin the hand-written set against the generatedTypedDictso a regeneration cannot quietly add an unreachable filter — seetests/v2/test_roles_resource.py. And the same again for the envelope, which is the one that got away:per_page,offset,paginateandcountare_RESERVED_QUERY_PARAMSinscripts/generate_v2_constants.py— deliberately kept out of every*FiltersTypedDict because each belongs on the method as an explicit typed parameter. Only the first two ever were.paginate/countwere reserved and then never implemented on any of the 68 list methods, and no sweep could see it:FIELDSandEXPANDwere the only golden tables the generator emitted, so the two rules that were swept were the only two that could be.AuditLogswas unusable outright (count_styles_enabled = Falseserver-side means the offset envelope 400s, sopaginate="cursor"is the only way in), theCursorPagebranch ofparse_pagewas unreachable from any public method, andcount=falsewas unsendable while_find_onehad been sending it internally all along. The generator now emits aPAGINATIONtable andtests/v2/test_pagination_coverage.pysweeps against it:listexposes every envelope param its operation declares,iterateexposesper_page/paginatebut notoffset/count(the auto-pager owns its own walk, and aCOUNT(*)per page is the cost the cursor envelope exists to avoid), andwork_item_relation_definitions_list— the one list operation the golden gives nopaginate— is pinned as not having one, so the fix stays golden-driven rather than blanket-applied.?cursor=is the exception the golden does not document:iteratehas always sent it, and a caller handed aCursorPage.next_cursorit cannot spend has a dead end, solisttakes it too. -
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 (client.v2.workspaces.projects.cycles.work_items, and likewise formilestones.work_items/modules.work_items/initiatives.projects/initiatives.work_items/customers.work_items/releases.work_items— all wired and reachable throughclient.v2, and through a fetched row's own property:cycle.work_items.add(["w1"])).ws.releases.labels(verbs sit next to the CRUD since it's also the label catalog) is another reachable bridge, asadd(slug: str, release: str, label_ids: Sequence[str]) -> list[str]/remove(slug, release, label_ids)— every leading path id the bridge's own URL needs, in path order, not just the parent id, then the ids to add/remove. Every bridge delegates 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 setsextra_paths, aClassVar[dict[str, str]]mapping a method name to its own override template;url_for(method, **path_params)(called by_bridge, and by any other method that needs a non-pathURL) fillsextra_paths.get(method, self.path)instead ofself.pathunconditionally.ReleaseLabelsis one example: its catalog CRUD hitspath(.../releases/labels/), whileextra_paths = { "add": "/workspaces/{slug}/releases/{release_id}/labels/", "remove": "/workspaces/{slug}/releases/{release_id}/labels/", }
sends
add/removeto the per-release URL instead. A catalog with no such bridge is not a child of the family at all:ReleaseTagssat atws.releases.tagswith nothing per-release to reach (a release points at a tag through its owntag_idfield), which maderelease.tagsa navigation property whose every call raised — a shell kept only to satisfy the navigation sweep. It isclient.v2.workspaces.release_tagsnow. Attach a catalog where its own URL is scoped, not next to the family it reads well beside. The retiredbridge_path(a single override for the whole class) is gone;url_forraisesTypeErrorif a class still declares it. 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_oneby default — one request withper_page=2, which is what tells "no match" apart from "ambiguous". 33 of the 38find_by_*methods in the package are that shape. It is written this way round deliberately: the earlier wording read as an exhaustive list of the server-side cases, naming three of the 33, so the common case looked like the exception.A method scans client-side only where the golden's list operation has no filter for that key, and there are five:
Collections.find_by_name(the API silently ignores a?name=, confirmed live),ProjectPages.find_by_nameandWikiPages.find_by_name,Roles.find_by_name(roles_listfilters onis_system/namespace/search/slug, notname— its siblingfind_by_slugis server-side) andWorkItemRelationDefinitions.find_by_name. Each says so in its own docstring; the docstrings have always been right, and they are the thing to trust over any list here. A newfind_by_*joins the default unless the golden leaves it no choice.Property
nameis the machine key, not thedisplay_namelabel — resources carrying both offerfind_by_nameandfind_by_display_nameseparately.
-
-
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