Studio API

Every Studio action is an API call you can make yourself.

Reference9 min read

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.

needs Postgres

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

MethodPathScope
GET/openapi.jsonstudio:read
GET/treestudio:read
GET/collectionsstudio:read
GET/editor-componentsstudio:read
GET/asset-urlstudio:read
POST/compilestudio:write
GET/changesstudio:read
GET/changes/diffstudio:read
POST/changes/commitstudio:publish
GET/compilationsstudio:read
GET/compilations/:id/revertstudio:read
POST/compilations/:id/revertstudio:publish
GET/branchesstudio:read
GET/approvalsstudio:read
POST/approvals/:id/decideapprovals:decide

The editor uses these, which work the same whether saves go to files or to GitHub:

MethodPathScope
GET/workspacestudio:read
GET/entries?collection=…studio:read
GET/entry?collection=…&slug=…studio:read
PUT/entrystudio:write
POST/entrystudio:write
DELETE/entrystudio:write
POST/entry/duplicatestudio:write
POST/entries/fieldstudio:write
GET/entry/history?path=…studio:read
GET/entry/version?path=…&id=…studio:read
GET/activity?limit=…studio:read
GET/draftsstudio:read
GET/drafts/diff?path=…studio:read
POST/drafts/publishstudio:write
POST/drafts/discardstudio:write

Media, agent tokens and the site's settings belong to the project, not to a branch:

MethodPathScope
GET/assets?q=…studio:read
GET/assets/detail?key=…studio:read
POST/assetsstudio:write
PUT/assets/altstudio:write
DELETE/assets?key=…studio:publish
GET/agentsstudio:settings
POST/agentsstudio:settings
DELETE/agents/:idstudio:settings
GET/sitestudio:read
PUT/sitestudio:settings
POST/site/hooksstudio:settings
POST/site/hooks/:id/teststudio:settings
DELETE/site/hooks/:idstudio:settings

People are Studio accounts, kept in the project's database. These routes answer ROUTE_NOT_FOUND on a Studio without accounts:

MethodPathScope
GET/peoplestudio:people
POST/people/invitesstudio:people
DELETE/people/invites/:idstudio:people
PUT/people/:idstudio:people
POST/people/:id/resetstudio:people
DELETE/people/:idstudio: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

ScopeWhy it is separate
studio:readLooking at the project
studio:writeChanging it: saving drafts, uploading files, compiling
studio:publishCommitting and reverting on your own machine, putting drafts live on a hosted Studio without a review, and deleting files
approvals:decideThe human gate. No agent token carries it
studio:peopleInviting people and changing their roles. Admins only
studio:settingsAgent 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 Origin header, its host must match the server's. Origin: null is refused. Only the host is compared, not http versus https, 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.

StatusCodes
400INPUT_VALIDATION_FAILED, SCHEMA_VALIDATION_FAILED, INVALID_SLUG, AUTHORITY_MISMATCH
401UNAUTHORIZED, TOKEN_INVALID
403APPROVAL_SELF_DECISION
404DOCUMENT_NOT_FOUND, COLLECTION_NOT_FOUND, APPROVAL_INVALID, ROUTE_NOT_FOUND
405METHOD_NOT_ALLOWED
409GIT_UNAVAILABLE, COMMIT_FAILED
500Anything 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",
});
OptionPurpose
contentDirAbsolute content root. Required for document reads and writes
projectRootWhere graft/ lives, for the project's editor component declarations. Defaults to the content directory's parent
mdxTrustHow much MDX the check that follows a local save accepts, as in graft compile
defaultBranchBranch used for compile and the tree default
deciderIdentity approval decisions are attributed to when authenticate is not configured. A value or a function. Defaults to { kind: "human", id: "studio" }
uiBasePathURL 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.