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.