Getting started

The NodePen API lets a script read what a team holds, and manage its Grasshopper plug-ins. Everything lives under /api/v1 on https://app.nodepen.io. Anything outside that prefix is the web app talking to itself — those endpoints change with the UI and will refuse an API token.

Nothing here changes a document. Running a saved version takes a body, because a caller has to say what to run the script with, and even that writes nothing and leaves nothing behind. The one thing a token can change is the team's plug-ins, and only with the plugin:write scope.

The shape of a document in particular is the app's own internal type and will change. Nothing here is deleted or renamed without warning, but do not assume the stability you would expect of a frozen API yet. Tell us what you are building and it will shape what stabilises first.

Your first request

Create a token in Team settings → API tokens, then start here. Everything else needs an id, and this is where you get the first one. The token already says which team, so there is nothing to pass.

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/team
{ "team": { "name": "My Team", "rootFolderId": "O7VSE6B0KZE2" } }

A folder, and what is in it

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/folders/O7VSE6B0KZE2

root works in place of an id and gives you the same folder /api/v1/team points at, so a script that only wants to walk the tree can start here and skip the call above.

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/folders/root
{
  "folder": {
    "id": "O7VSE6B0KZE2",
    "name": "Root Folder",
    "parentId": null,
    "path": [],
    "folders": [{ "id": "K2P0LMN4QRST", "name": "Studies" }],
    "documents": [
      {
        "id": "PH1J4SA5JBEK",
        "name": "Bridge study",
        "visibility": "private",
        "createdAt": "2026-09-04T09:21:11.972Z",
        "updatedAt": "2026-09-04T09:21:12.402Z"
      }
    ]
  }
}

path is the ancestors of this folder, root first, and is empty at the root. folders and documents are only what is directly inside — walk folders to go deeper.

Finding a document

Walking folders only finds a file if you already know which folder it is in. This is the other way round — the same search the search bar on the team page runs, over everything the team keeps:

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/documents?q=bridge"

q matches anywhere in a name and ignores case, so bridge finds Bridge study and Cable-stayed bridge alike. Names the query starts come first, and the rest are ordered by when they were last changed. % and _ are matched as themselves rather than as wildcards, so searching for 50% finds 50% scale.

Leave q off and you get the team's documents, newest first — a bare /api/v1/documents is the "what has this team got" call rather than an error.

folderId narrows it to one folder and everything inside it, at any depth. root works in place of an id here just as it does above. A folder that is not there and a folder that is not yours are both 404, in the same words.

folder is where each document is kept: hand its id to /api/v1/folders/{folderId} to see what else is in there. isRoot marks the folder a file sits in when nobody has filed it anywhere — that folder's name is an internal placeholder, so show your team's own name against it rather than what is in name.

At most 50 documents come back and there is no paging yet, so narrow with q or folderId rather than expecting to walk the whole team.

Reading a document

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK

The nested document is the script itself, and it is the same live state the editor is showing — edits made a second ago are in it. document.version is the number to branch on if this shape changes. It is 1 and has never been anything else.

Within a node, inputs and outputs map port ids to their order; sources is the wiring, mapping one of your input ports to the upstream { nodeInstanceId, portInstanceId } feeding it; and values holds anything typed in by hand rather than computed.

The component library

A document names its components by id and says nothing about what they are. /api/v1/templates is the other half:

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/templates

Every node's templateId is the guid of one of these, and the template is what says the node is an Addition, what its ports are called and what they carry. order is the join — a node's inputs and outputs map each port instance id to one of those numbers:

const template = templatesByGuid[node.templateId]
const port = template.inputs.find((p) => p.order === node.inputs[portInstanceId])

This is the same list the editor draws with: Grasshopper's full set filtered to what NodePen can render. A component missing from here is one a NodePen document cannot contain, so it is also the answer to "what can I build with".

Icons are off by default — each is a base64 png of about 1.6 kB, which across the library is the difference between roughly 650 kB and 1.4 MB. Ask for them when you are drawing something:

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/templates?includeIcons=true"

The list changes only when NodePen deploys, so it is worth holding on to rather than fetching per document. It is also the one endpoint that needs no scope: it is the same for every team and says nothing about yours.

Reading a saved version

A version is a snapshot somebody chose to keep. Reading a document gives you it as it is right now — this is the only way to read one as it was.

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/versions
{
  "versions": [
    {
      "id": "9KQ2MZ4TF",
      "serialId": 2,
      "label": "R002",
      "description": "After the cable change",
      "createdAt": "2026-09-04T11:02:44.108Z"
    },
    {
      "id": "4B7XW1PCD",
      "serialId": 1,
      "label": "R001",
      "description": null,
      "createdAt": "2026-09-04T09:38:02.771Z"
    }
  ]
}

serialId counts up from 1 within a single document, so it orders the list as well as names it: what comes back is most recent first. label is that number written the way the editor writes it, so a script and the app call a snapshot by the same name.

The captured scripts are not in the listing — each is a whole document. Ask for one by id:

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/versions/9KQ2MZ4TF

That answers in the same shape as reading the document: the summary, and the script nested under document. Same key, same narrowing, same document.version — whatever parses one parses the other, and the fields around it say which moment you are looking at.

Neither call is gated by plan — versions are part of the editor on every tier, and this API says the same.

Reading a solution

A solution is what the solver worked out for a document: a tree of values per node, and optionally the geometry that goes with them.

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/solution

data is the solve as the solver produced it, keyed by node instance id — the same values the editor draws. data.solutionId names this solve, so you can tell one from the next.

By default you get the whole solve, every node in it — a script's working as well as what it published. Ask for ?scope=output to narrow it to the script's outputs: the nodes named by its controls.output, which are the handful its author pinned as its results.

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/solution?scope=output"

The narrowing is by node, not by port: an output control names one port, but the node behind it solved as a whole and its other ports are how you tell what it did. A document whose author pinned no outputs answers output with an empty nodeSolutionData rather than quietly falling back to everything — scope comes back in the response, so check the document's controls.output if an empty result is a surprise.

Geometry is off by default, because it is by far the largest part of a solve and most callers only want the tree.

A solve is kept in two halves. The tree — branches, paths, types, descriptions, stats, per-node runtime data — is what you get by default, and every value's value is null in it. Ask for the other half and each geometric value comes back with its own geometry in value, serialized the way rhino3dm reads it:

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/solution?includeGeometry=true"

Asking for geometry is a request, not a guarantee. A solve from before geometry moved out of the tree, or one a maintenance pass has been over, has none to read, and the read can fail — all of those still answer 200 with the tree, because the tree is the real result. includesGeometry is what tells you which you got, so check it rather than assuming.

?format=bimz answers with the same solve as a BIM object document instead: a flat store of objects keyed by platform id, one per value in a data tree branch, each carrying its geometry. It always reads everything it can, so includeGeometry does not apply to it.

The script's own objects — one per node, port and branch — are left out of that file by default. They carry no geometry: they are there so a value's relation to what produced it has somewhere real to point. ?includeScript=true puts them back, and points each value at the three it came from:

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/solution?format=bimz&includeScript=true"

Without them, every value still names the node, port, branch path and branch index it came from in its own attribution — what it loses is the connects/source relation, dropped rather than left naming objects that are no longer in the file.

A value's own geometry is only ever the real thing. The format has points, lines, polylines and triangle meshes and nothing else, so a value the solver produced as a brep or a surface carries no geometry of its own. What every geometric value does carry is Rhino's own description of it: nodepen.value.value in its attribution is the geometry's ToJSON, as a string, which rhino3dm turns back into the real object:

const value = bimObject.attribution['nodepen.value.value']
const geometry = rhino.CommonObject.decode(JSON.parse(value)) // a Brep, a NurbsCurve, ...

nodepen.value.type says what each value really is. A value with no geometry — a number, a string — has no nodepen.value.value.

That value lives in the solve's stored results, so if they can't be read at the moment the request answers 503 rather than leaving it out; try again shortly.

Exporting a document

Grasshopper (.gh) and Rhino (.3dm), the same two formats the editor offers.

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/export?format=.3dm&rhinoVersion=8"

The response is a link, not the file. Follow it to download:

curl -o nodepen-export.3dm "$(curl -s -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/export?format=.gh" | jq -r .export.url)"

format is required and takes the leading dot. rhinoVersion is 7, 8 or 9, defaults to 9, and is only accepted alongside format=.3dm — sending it with .gh is an error rather than being quietly ignored. The url stops working at expiresAt, an hour out.

A .3dm export is the solution model by default: every result exactly as Grasshopper solved it. geometryType=display asks for the editor's picture of the same solve instead — breps and surfaces tessellated into meshes, plus whatever preview-only components draw. Like rhinoVersion, it is only accepted alongside format=.3dm, and the response's geometryType says which one you got.

curl -H "Authorization: Bearer $NODEPEN_TOKEN" \
  "https://app.nodepen.io/api/v1/documents/PH1J4SA5JBEK/export?format=.3dm&geometryType=display"

Writing

Not yet, and not by accident. A NodePen document is a CRDT that several people and the agent may have open at once, so "replace this with that JSON" is last-writer-wins against whoever is editing right now — which is precisely what the editor's own save path goes out of its way not to be. Rather than ship a write endpoint that quietly loses work, there is none. If you need one, say what you are trying to do; it changes what the right shape is.