Studio API
Every Studio action is an API call you can make yourself.
Use this page to script anything Studio does, or to build your
own editing UI. Studio has no special access: everything it does is a call to
/api/studio/v1/*, and you can make the same calls.
The OpenAPI document
curl http://127.0.0.1:4983/api/studio/v1/openapi.json
It describes every route and payload exactly. The tables below are a summary.
On a hosted site, graft api makes these calls for you with a token saved by
graft login, so the token never goes into a command line. graft api alone
lists the routes from this document. See
Call the Studio API.
Routes
| Method | Path | Scope |
|---|---|---|
GET | /openapi.json | studio:read |
GET | /tree | studio:read |
GET | /collections | studio:read |
GET | /editor-components | studio:read |
GET | /asset-url | studio:read |
POST | /compile | studio:write |
GET | /changes | studio:read |
GET | /changes/diff | studio:read |
POST | /changes/commit | studio:publish |
GET | /compilations | studio:read |
GET | /compilations/:id/revert | studio:read |
POST | /compilations/:id/revert | studio:publish |
GET | /branches | studio:read |
GET | /approvals | studio:read |
POST | /approvals/:id/decide | approvals:decide |
The editor uses these, which work the same whether saves go to files or to GitHub:
| Method | Path | Scope |
|---|---|---|
GET | /workspace | studio:read |
GET | /entries?collection=… | studio:read |
GET | /entry?collection=…&slug=… | studio:read |
PUT | /entry | studio:write |
POST | /entry | studio:write |
DELETE | /entry | studio:write |
POST | /entry/duplicate | studio:write |
POST | /entries/field | studio:write |
GET | /entry/history?path=… | studio:read |
GET | /entry/version?path=…&id=… | studio:read |
GET | /activity?limit=… | studio:read |
GET | /drafts | studio:read |
GET | /drafts/diff?path=… | studio:read |
POST | /drafts/publish | studio:write |
POST | /drafts/discard | studio:write |
Media, agent tokens and the site's settings belong to the project, not to a branch:
| Method | Path | Scope |
|---|---|---|
GET | /assets?q=… | studio:read |
GET | /assets/detail?key=… | studio:read |
POST | /assets | studio:write |
PUT | /assets/alt | studio:write |
DELETE | /assets?key=… | studio:publish |
GET | /agents | studio:settings |
POST | /agents | studio:settings |
DELETE | /agents/:id | studio:settings |
GET | /site | studio:read |
PUT | /site | studio:settings |
POST | /site/hooks | studio:settings |
POST | /site/hooks/:id/test | studio:settings |
DELETE | /site/hooks/:id | studio:settings |
People are Studio accounts, kept in the project's database. These routes answer
ROUTE_NOT_FOUND on a Studio without accounts:
| Method | Path | Scope |
|---|---|---|
GET | /people | studio:people |
POST | /people/invites | studio:people |
DELETE | /people/invites/:id | studio:people |
PUT | /people/:id | studio:people |
POST | /people/:id/reset | studio:people |
DELETE | /people/:id | studio:people |
All paths are prefixed with /api/studio/v1. Sign-in lives beside them at
/api/studio/v1/auth/* and needs no scope, because it is how a session starts.
GET /entries takes q, status (all, unpublished, published, new,
changed, deleted), where.<field>=<value>, sort (site, title, updated or a
field), dir, cursor and limit (at most 500), and returns a page with the
facets to filter by.
GET /entry/history lists an entry's published versions, newest first: its
revisions when content is in the database, the production branch's commits on
GitHub, and the checkout's commits locally. Where there is no history it
answers available: false with a reason. GET /entry/version returns one
version's raw bytes and a diff from it to what the caller has now. To
restore it, send that raw to PUT /entry: a restore is a draft. GET /activity lists recent publishes across the content (at most 100), each with
who made it and the first few entries it changed.
PUT /entry takes { collection, slug, data, body, baseVersion }, where
baseVersion is the version from GET /entry. A stale one is refused with
CONTENT_CONFLICT (409) and nothing is written. POST /drafts/publish takes
{ paths, message?, resolve? }; a caller without studio:publish always opens
a pull request on a hosted Studio.
POST /entries/field sets one field on many entries, each saved as a draft:
{ collection, slugs, field, value }, at most 500 slugs. "value": null
removes an optional field. Each entry is validated and written as PUT /entry
would. The answer lists saved entries with their new versions and failed
ones with their error code, message and fix; one entry that fails doesn't stop
the others.
GET /people lists who can sign in, open invitations, and autoJoin, the email
domains that join on their own (GRAFT_STUDIO_AUTO_JOIN). POST /people/invites
takes { email, name?, role? } (role defaults to editor) and returns the
invitation link once, since only its hash is stored. PUT /people/:id takes
{ role }. POST /people/:id/reset makes a link to set a new password.
DELETE /people/:id removes someone and ends their sessions. Nobody can remove
their own access, and the last admin can't be removed or made anything else.
POST /assets takes { name, base64, key?, alt?, overwrite? }, at most
10 MB, and stores the file at assets/<name> unless key says otherwise. It
takes images and PDFs, typed by their extension. A key that is taken is refused
with ASSET_EXISTS (409). overwrite: true replaces it, which changes the
live site at once, so it needs studio:publish. DELETE /assets refuses a file an entry still uses with
CONTENT_CONFLICT (409), naming the entries. Without an asset store, the media
routes answer storage: false or ENV_VAR_MISSING (503).
POST /agents takes { name, access }, where access is read, write
(drafts only) or publish, and returns the token once. Only its hash is
stored. No access level carries approvals:decide.
Revert has two methods on one path. GET checks whether a revert is safe and
needs only a read scope. POST performs it and needs a write scope.
Scopes
| Scope | Why it is separate |
|---|---|
studio:read | Looking at the project |
studio:write | Changing it: saving drafts, uploading files, compiling |
studio:publish | Committing and reverting on your own machine, putting drafts live on a hosted Studio without a review, and deleting files |
approvals:decide | The human gate. No agent token carries it |
studio:people | Inviting people and changing their roles. Admins only |
studio:settings | Agent tokens, the site's address and deploy hooks. Admins only |
Being signed in grants nothing by itself. A caller needs the scope for each kind of action, so an agent can be allowed to read without being able to commit.
Authentication
Pass an authenticate function that turns a request into a principal (who is
calling, and with which scopes), or returns null to refuse it.
createStudioHandler({
db,
collections,
contentDir,
authenticate: (request) =>
tokenIsValid(request) ? { kind: "human", id: "you@example.com", scopes: [...] } : null,
});
A principal is { kind, id, scopes? }. Absent scopes means none, not all.
Omitting authenticate entirely means the mount is unauthenticated, which is
only appropriate on loopback.
Requests that change data
Every POST and PUT must pass two checks. Together they stop a web page you
happen to have open from making changes through a Studio running on your
machine, which asks for no login:
- Same host. If the request has an
Originheader, its host must match the server's.Origin: nullis refused. Only the host is compared, nothttpversushttps, so saves still work behind a proxy that handles HTTPS. - JSON only. The request must send
Content-Type: application/json. A browser cannot send that to another site without first asking permission, which Studio never grants.
Decide an approval
curl -X POST http://127.0.0.1:4983/api/studio/v1/approvals/ap_123/decide \
-H 'content-type: application/json' \
-d '{"decision":"approved"}'
decision must be "approved" or "denied". Anything else is
INPUT_VALIDATION_FAILED.
There is no field to say who decided. The decision is recorded against the
identity Studio was set up with (see decider below), so a caller cannot name
someone else as the decider and approve their own request.
Deciding a row that is not pending answers APPROVAL_INVALID.
Commit changes
curl -X POST http://127.0.0.1:4983/api/studio/v1/changes/commit \
-H 'content-type: application/json' \
-d '{"paths":["docs/tiers.mdx"],"message":"Add the tier page"}'
Paths are relative to the content folder. Only files in the current list of changes can be committed, so this endpoint cannot commit an arbitrary file.
An empty message or an empty path list is INPUT_VALIDATION_FAILED. A path that
is not in the current change set is too, and says to reload the list. A project
not under git is GIT_UNAVAILABLE. Committer identity is checked before
anything is staged, so a failure leaves the git index untouched.
Errors
GraftErrors throughout, mapped to status codes.
| Status | Codes |
|---|---|
| 400 | INPUT_VALIDATION_FAILED, SCHEMA_VALIDATION_FAILED, INVALID_SLUG, AUTHORITY_MISMATCH |
| 401 | UNAUTHORIZED, TOKEN_INVALID |
| 403 | APPROVAL_SELF_DECISION |
| 404 | DOCUMENT_NOT_FOUND, COLLECTION_NOT_FOUND, APPROVAL_INVALID, ROUTE_NOT_FOUND |
| 405 | METHOD_NOT_ALLOWED |
| 409 | GIT_UNAVAILABLE, COMMIT_FAILED |
| 500 | Anything unexpected |
GIT_UNAVAILABLE and COMMIT_FAILED are 409: the request is fine and the
server works, but the repository is in a state that cannot do what was asked.
A route id containing malformed percent-encoding is a bad request rather than a server error, and says so.
Mount it yourself
createStudioApiHandler gives you the API alone. createStudioHandler adds the
SPA.
import { createStudioHandler } from "@usegraft/studio";
const handler = createStudioHandler({
db,
collections,
contentDir,
mdxTrust,
defaultBranch: "main",
decider: { kind: "human", id: "ops@example.com" },
authenticate,
uiBasePath: "/studio",
});
| Option | Purpose |
|---|---|
contentDir | Absolute content root. Required for document reads and writes |
projectRoot | Where graft/ lives, for the project's editor component declarations. Defaults to the content directory's parent |
mdxTrust | How much MDX the check that follows a local save accepts, as in graft compile |
defaultBranch | Branch used for compile and the tree default |
decider | Identity approval decisions are attributed to when authenticate is not configured. A value or a function. Defaults to { kind: "human", id: "studio" } |
uiBasePath | URL prefix for the UI. Empty string serves it at / |
API paths are always absolute /api/studio/v1/* regardless of uiBasePath.
An approval is recorded against the caller authenticate returns. That is the
setting that matters when several people share one Studio: return each
person's own identity from authenticate, and the approval history says who
approved what. A decision is never attributed to a name the request supplies.
decider applies only when authenticate is not configured, as on a local
Studio for one person. When authenticate is configured and returns null,
the request is refused with UNAUTHORIZED, so nothing falls back to decider. If you leave it unset there, decisions are recorded
against { kind: "human", id: "studio" }, which works with no setup.
The UI opens on the branch Studio was started on. A ?branch= in the URL
picks a different one. The branch-scoped routes, such as the content tree and
compile, use the started branch only when the URL names none. The compilation
history is the exception: without ?branch, it lists every branch.
Next steps
- Studio shows what the UI does with all of this.
- Hardening explains why decide can fail on a deployment.
- CLI reference covers the same operations as commands.
- MCP tools covers them as tools.