A comprehensive TypeScript/JavaScript SDK for the Plane API, providing a clean and type-safe interface for all Plane operations.
npm install @makeplane/plane-node-sdkImportant: Version 0.2.0 introduces breaking changes with new API signatures. If you're migrating from version 0.1.x, please review the following:
- New PlaneClient Structure: Instead of importing each API separately, you now use a single
PlaneClientinstance that provides access to all APIs - Updated Method Signatures: Method parameters and return types have been updated for better readability and consistency
Migration Guide:
- Replace individual API imports with the new
PlaneClientapproach - Review the new API documentation for updated method signatures
- Test thoroughly in a development environment before upgrading
import { PlaneClient } from "@makeplane/plane-node-sdk";
const client = new PlaneClient({ apiKey: "your-api-key" });
// Or against a self-hosted instance, with an OAuth access token:
const selfHosted = new PlaneClient({
baseUrl: "https://plane.example.com",
accessToken: "your-access-token",
});
// Every workspace-scoped call takes the workspace slug first.
const projects = await client.projects.list("workspace-slug");
const project = await client.projects.create("workspace-slug", {
name: "My Project",
description: "A new project",
});
void selfHosted;
void projects;
void project;client.v2 reaches the v2 surface. The v1 resources on the client are unchanged.
There are two ways in, and they are the same resources either way.
A resource hangs off the namespace at the position its URL puts it, and takes the ids its URL names as leading positional arguments, in path order:
import { PlaneClient } from "@makeplane/plane-node-sdk";
const client = new PlaneClient({ baseUrl: "https://api.plane.so", apiKey: "..." });
// GET /workspaces/acme/projects/ENG/states/
await client.v2.workspaces.projects.states.list("acme", "ENG");
// GET /workspaces/acme/projects/ENG/work-items/wi-1/comments/
await client.v2.workspaces.projects.workItems.comments.list("acme", "ENG", "wi-1");
// GET /workspaces/acme/teamspaces/
await client.v2.workspaces.teamspaces.list("acme");project accepts a project's UUID or its readable identifier ("ENG"); a work item
can be reached by its human key with retrieveByIdentifier. No v2 method takes a
workspaceSlug or project option object — path ids are positional and always first,
in URL order, and everything else lives in the trailing params object.
A resource that has children answers navigable rows from every row-returning method:
the row's own data, plus one property per child, with the ids that produced it already
supplied. This is the id-consumption rule — an id is passed once, at the point it is
known. (A resource with no children — states, labels, roles — answers the plain
model, since there is nothing to reach from it.)
const workspace = await client.v2.workspaces.retrieve("acme");
await workspace.projects.list(); // no slug
await workspace.teamspaces.list(); // no slug
// And it chains: a fetched project carries both ids.
const eng = await workspace.projects.retrieve("ENG");
await eng.states.list(); // no slug, no project key
await eng.workItems.create({ name: "Fix login bug", state: "Todo", labels: ["bug"] });
// Three levels deep: a fetched work item carries all three.
const item = await eng.workItems.retrieve("wi-1");
await item.comments.list();Chaining works because a row knows which ids fetched it. list and iterate hand back
navigable rows too, so paging does not lose navigation:
for await (const project of client.v2.workspaces.projects.iterate("acme")) {
await project.states.list(); // still navigable
}Three details worth knowing:
row.$loadedcarriesids,idNamesandpresent— the set of field names the server actually returned. It and the navigation properties are non-enumerable, so{ ...row },Object.keys(row)andJSON.stringify(row)see the plain API row.- A navigation property never shadows a field. Where a child's natural name is
already a field of the row, the property is renamed and the field is kept:
estimate.estimatePoints(because?expand=pointsreturns a realpointsfield) andproperty.propertyOptions(likewiseoptions). Building a row that would shadow a field throws rather than hiding data. - Only methods survive navigation. A grandchild resource is not reachable from a
view —
project.workItems.commentsdoes not exist, because a comment needs a work item's own id, which only a fetched work item carries. Fetch the work item first.
There is no third form. client.v2.workspace(slug).project(key) — the bound locator
chain earlier previews carried — is deleted, not deprecated: it bound nothing (every
resource takes its ids per call, so workspace(slug).roles.list(slug) passed the slug
twice) and every family it held is on v2.workspaces already.
fields narrows the return type, not just the response. Ask for two fields and the
row you get back has two fields plus id; reading anything else is a compile error, not
an undefined at runtime:
const page = await client.v2.workspaces.projects.states.list("acme", "ENG", { fields: ["id", "name"] });
for (const state of page.data) {
console.log(state.id, state.name); // typed — only id/name exist on this row
// console.log(state.color); // compile error: `color` was not requested
}An inline array literal needs no as const. The same holds for iterate, for
retrieve, and — since it is the same claim — for writes: create, update and
upsert accept fields and narrow their answer the same way.
const created = await client.v2.workspaces.projects.states.create(
"acme",
"ENG",
{ name: "In Review", color: "#4ECDC4" },
{ fields: ["id", "name"] }
);
console.log(created.name); // narrowed; `created.color` would not compileA field list built at runtime (not a literal) must still be typed as field names — plain
string[] is not assignable to readonly StateField[] and fails with a long
overload-mismatch error. The row is narrowed to the list's element type, so type the
variable as narrowly as you actually use it:
import { PlaneClient, v2 } from "@makeplane/plane-node-sdk";
const client = new PlaneClient({ baseUrl: "https://api.plane.so", apiKey: "..." });
// Narrowed to these two names, even though the value is chosen at run time.
const wanted: ("id" | "name")[] = includeColor ? ["id", "name"] : ["id"];
const page = await client.v2.workspaces.projects.states.list("acme", "ENG", { fields: wanted });
// Typed as the whole union instead, this narrows to "every field" — i.e. the full row.
const anything: v2.StateField[] = includeColor ? ["id", "name", "color"] : ["id", "name"];
const unnarrowed = await client.v2.workspaces.projects.states.list("acme", "ENG", { fields: anything });"all" is a legal field value meaning "every field" and correctly yields the full row
type. Sparse responses mean every read field except id is optional — check for
undefined rather than assuming presence, and row.$loaded.present answers what
actually came back when the list was built dynamically.
One limitation, and it is the reason the flat form stays public. A navigated call
resolves against the general signature, so it accepts fields but does not narrow:
const eng = await client.v2.workspaces.projects.retrieve("acme", "ENG");
const listed = await eng.states.list({ fields: ["id", "name"] }); // Page<State>, not narrowedTypeScript erases a type parameter when it infers through the conditional type behind a
navigation property, so the narrowing overload cannot be carried across. Matching the
overload set instead would be worse, not better: the inference would erase F to its
constraint and claim every field is present. When you want the narrowed row, call the
resource flat — client.v2.workspaces.projects.states.list("acme", "ENG", { fields: [...] }).
Memberships are add/remove on a sub-resource named for the thing being added — path
ids first, then 1..100 ids (v2.BRIDGE_MAX_IDS; an empty or oversized list throws before
any request). Each call sends only its own verb and resolves to the ids the server
actually changed.
There are two caps, and they are different numbers. A membership bridge takes up to
100 ids per call (v2.BRIDGE_MAX_IDS, the golden's maxItems on the 24 add/remove
schemas); a bulk write takes up to 50 items per call (v2.BULK_MAX_ITEMS, the golden's
maxItems on the 21 bulk create/update/delete schemas). Sizing a bridge call at 50 works
but wastes half of each round trip; sizing a bulk call at 100 throws client-side.
Properties on a work item type use link/unlink instead, matching the web app;
unlink deletes that property's values on every work item of the type.
const v2ns = client.v2;
await v2ns.workspaces.projects.cycles.workItems.add("acme", "ENG", cycleId, [itemId]); // -> ["<item id>"]
await v2ns.workspaces.projects.modules.workItems.remove("acme", "ENG", moduleId, [itemId]);
await v2ns.workspaces.releases.labels.add("acme", releaseId, [labelId]);
await v2ns.workspaces.initiatives.projects.add("acme", initiativeId, [projectId]);
await v2ns.workspaces.wiki.collections.members.add("acme", collectionId, [{ member_id: userId, access: 1 }]);
await v2ns.workspaces.projects.workItemTypes.properties.link("acme", "ENG", typeId, [propertyId]);
await v2ns.workspaces.projects.workItemTypes.properties.unlink("acme", "ENG", typeId, propertyId);releases.labels is the one place where a fetched row and the flat path differ: the
class holds both the workspace-level label catalog (list/create, slug only) and the
per-release bridge. A fetched release binds the bridge, so release.labels.add(...)
works and release.labels.list() does not type-check — reach the catalog flat.
These resolve exactly one row server-side, throwing NoMatchFoundError or
MultipleMatchesFoundError otherwise: findByName wherever the API filters on name,
plus roles.findBySlug, estimates.points.findByKey, and findByName on property
options and contexts. Both errors extend PlaneError, not PlaneApiError. On custom properties name is the machine key (e.g.
story_points), not the label shown in the app.
await client.v2.workspaces.projects.states.findByName("acme", "ENG", "Todo");
await client.v2.workspaces.roles.findBySlug("acme", "admin", { namespace: "workspace" });
await client.v2.workspaces.projects.estimates.points.findByKey("acme", "ENG", estimateId, 3);Page<T> is a union of the offset envelope (total_count, next) and the cursor
envelope (has_more, next_cursor) — which one a list() returns depends on the
request's paginate param. Narrow it with v2.isCursorPage / v2.isOffsetPage before
reading an envelope-specific field; reading one without narrowing is a compile error,
since it may not exist on the other half of the union:
const page = await client.v2.workspaces.projects.states.list("acme", "ENG");
if (v2.isOffsetPage(page)) {
console.log(page.total_count); // only reachable once narrowed
} else if (v2.isCursorPage(page)) {
console.log(page.next_cursor);
}iterate follows pages for you and yields rows.
order_by is validated the same way fields is, against a generated
StateOrderBy/LabelOrderBy union: a literal outside that union is a compile error, and a
value built at run time is rejected by encodeOrderBy client-side rather than reaching
the server as a 400.
v2.workspaces.wiki.pages is every global page in the workspace (not one project —
that is v2.workspaces.projects.pages); v2.workspaces.wiki.collections is wiki collections. A
page write's collection_id can be omitted for a public page (it lands in the
workspace's default "General" collection); a private page needs an explicit
collection_id of a collection the caller owns.
const wiki = client.v2.workspaces.wiki;
await wiki.pages.create("acme", { name: "Handbook" }); // public page -> default collection
const handbook = await wiki.collections.findByName("acme", "Engineering handbook");
await wiki.pages.create("acme", { name: "Runbook", collection_id: handbook.id });
await wiki.collections.default("acme"); // the default collection, resolved via `is_default`wiki and groupSync are grouping nodes, not resources: they consume no path id of
their own, so they are not navigation properties on a fetched workspace row. Reach them
flat.
PlaneApiError — an RFC 9457 problem detail with .status, .type, .code, .detail,
.errors — plus NoMatchFoundError and MultipleMatchesFoundError from the findBy*
lookups. A request that never reaches a server at all (connection refused, DNS failure,
timeout, …) raises PlaneNetworkError, carrying the underlying error's message and
.cause. A call that cannot build a URL because a path id is missing raises
MissingPathIdError.
bulkCreate / bulkUpdate / bulkDelete always answer HTTP 200, even when some rows
fail — partial success is the default. Call v2.raiseForFailures(result) to throw,
carrying the first failure's errors. The cap is 50 items per call
(v2.BULK_MAX_ITEMS) — not the 100 a membership bridge takes; an empty batch is rejected
client-side rather than being a silent no-op.
const result = await client.v2.workspaces.projects.states.bulkCreate("acme", "ENG", [{ name: "QA", color: "#ffffff" }]);
v2.raiseForFailures(result);v2 types are reachable through the v2 and v2models namespaces (e.g. v2models.State,
v2.StateField), and the most common ones are also aliased at the package root:
V2Label, V2State, V2Project, V2Workspace, V2WorkItem, V2Cycle, V2Module,
V2Milestone, V2ListStatesParams, V2ListLabelsParams. Use those — v2's bare
Label/State/Project/Workspace/Page names collide with v1's in the bundled type
definitions, so import { Workspace } from "@makeplane/plane-node-sdk" resolves to
v1's shape, not v2's. (V2Page is the wiki page model; the pagination envelope
Page<T> is aliased separately as V2PageEnvelope.)
The types a navigable row resolves to are exported too: LoadedProject,
ProjectNavigation, ProjectIds, PROJECT_ID_NAMES, and the same set per family, plus
the Loaded, Owned and LoadedMeta kernel types.
v2.FIELDS, v2.EXPAND and v2.ORDER_BY are the full operation-id → allowed-values
maps the encoders validate against (e.g. v2.FIELDS["states_list"] lists every field
states.list accepts); v2.OPENAPI_VERSION is the api_v2 golden version the SDK was
generated from. All of them, plus the two caps v2.BULK_MAX_ITEMS and v2.BRIDGE_MAX_IDS,
are exported so a caller can enumerate valid values rather than guessing.
The v2 surface is 90 resource classes, and none of it is spot-checked. Rule sweeps run over every class — enumerated from the TypeScript source, not selected by some property a class might not have yet — and each is proved by introducing the violation and watching the sweep name it:
| Sweep | What it refuses |
|---|---|
| Call shape | a method that does not open with its URL's path ids, in path order |
fields / expand |
an operation that offers a projection the SDK does not expose |
| Query filters | a ?filter= the API accepts and no params type declares |
order_by |
a missing sort order, or a params type pointed at a sibling's enum |
| Pagination | an unreachable half of the paging envelope — including a paginate with no cursor to spend it |
| Operation correspondence | a method with no operations entry, silently exempt from the above |
| Projection soundness | a method that accepts fields and answers the full row anyway |
| Loader routing | a row-returning method on a navigable class that skips load() |
| Alternate paths | a method that declares an extraPaths override and ignores it |
| Lookups | a findBy* filtering on something the API does not filter on |
Two more sweeps cover the tree rather than the classes: band completeness derives, from each resource's own URL template, which of the two roots it belongs to and requires it to be attached there (and nothing foreign to be); navigation completeness requires a resource that attaches a migrated child to answer navigable rows, with one property per child and no property shadowing a real field. Every method also asserts its exact request URL against a mock server — the one class of error no source-level sweep can catch.
- ✅ TypeScript support with full type safety
- ✅ Centralized HTTP logic with BaseResource
- ✅ Automatic authentication handling
- ✅ Modern async/await patterns
- ✅ Extensible architecture
- Projects: Project management and organization
- WorkItems: Issue and task management with full CRUD operations
- WorkItemTypes: Custom work item type definitions and management
- WorkItemProperties: Custom properties for work items
- Labels: Issue categorization and tagging
- States: Workflow state management
- Users: User management and profiles
- Roles: Workspace and project role definitions (read-only)
- Estimates: Project estimates and estimate points
- Modules: Feature organization and module management
- Cycles: Sprint and iteration management
- Customers: Customer management and operations
- Pages: Workspace and project page management
- Links: Work item linking and relationships
- Workspace: Workspace-level operations
- Epics: Epic management and organization
- Intake: Intake form and request management
- Stickies: Stickies management
- Teamspaces: Teamspace management
- Milestones: Milestone tracking and management
- Initiatives: Initiative management
- WorkspaceTemplates: Workspace-level work item, project, and page templates
- WorkspaceWorkItemTypes: Workspace-level work item type management with property links
- WorkspaceWorkItemProperties: Workspace-level custom property management with options
- WorkspaceProjectLabels: Workspace-level project label management
- WorkspaceProjectStates: Workspace-level project state management
- WorkItemRelationDefinitions: Custom work item relation type definitions
- Releases: Release management with tags, labels, item labels, changelog, comments, links, and work items
- Collections: Folders that group workspace pages, with member and page management
- AgentRuns: AI agent run orchestration and activity tracking
- Workflows: Project workflow management with state attachments, transitions, transition hooks, activities, and work item approvals
- ProjectTemplates: Work item and page template management per project
- Features: Workspace and project features management
- WorkspaceStates: Workspace-level (catalog) work-item states under workspace governance — dual-mode reads, governed-only writes
- WorkspaceWorkflows: Workspace-level workflow catalog under workspace governance, with chain (states), transitions, usage, activities, and transition hooks
- WorkItemTypeGovernance: Governs which workflows a workspace-level work item type may use (any/constrained/required modes), with per-project pins and the project-side pick/fallback-preview endpoints
# Install dependencies
pnpm install
# Build the project
pnpm build
# Run tests
pnpm test
# Lint (oxlint) — `pnpm fix:lint` to auto-fix
pnpm check:lint
# Format (oxfmt, 120 columns) — `pnpm fix:format` to rewrite
pnpm check:formatBefore running tests, you need to configure your test environment:
-
Copy the environment template:
cp env.example .env.test
-
Update
.env.testwith your test environment values:# Edit the file with your actual test environment details nano .env.test -
Required environment variables:
TEST_WORKSPACE_SLUG: Your test workspace slugTEST_PROJECT_ID: Your test project IDTEST_USER_ID: Your test user IDTEST_WORK_ITEM_ID: A test work item IDTEST_CUSTOMER_ID: A test customer ID- And other test-specific IDs as needed
# Run all tests (recommended)
npm test
# or
pnpm test
# Run one suite, or one file
pnpm test:unit
pnpm test -- tests/unit/page.test.tsMIT