# Studio API

> Every Studio action as an HTTP call you can script: routes, scopes, the checks on changes, and mounting it yourself.

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

Use this page to script anything [Studio](/docs/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 the Postgres storage engine.

## The OpenAPI document

```sh
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](/docs/cli#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.

```ts
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.

> **Why this is not a true/false setting.**
> A yes or no per request cannot say what a caller may do, only whether they
> may do anything. Returning scopes lets Studio allow reads but refuse commits
> or approvals for the same caller. An earlier yes-or-no version let any signed-in
> agent approve and deny.

## 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

```sh
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

```sh
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.

```ts
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](/docs/studio) shows what the UI does with all of this.
- [Hardening](/docs/hardening) explains why decide can fail on a deployment.
- [CLI reference](/docs/cli-reference) covers the same operations as commands.
- [MCP tools](/docs/mcp-tools) covers them as tools.
