Host Studio for your team
Give a content team a Studio URL. Their changes still end up as commits.
Locally, Studio saves to files and you commit. A hosted Studio has no files it can keep: a serverless function's filesystem is read-only, and a container's is thrown away on the next deploy. So a hosted Studio writes to GitHub instead. Every editor's saves go to a draft branch of their own, and publishing puts one commit on your production branch, or opens a pull request. Your host redeploys from that commit the way it would for a developer's push.
Nothing is stored outside Git. An editor's draft is a branch you can open on GitHub, and an agent with repository access can read or finish it with plain Git.
How it works
| Editor does | What happens in the repository |
|---|---|
| Saves | A commit on graft-studio/drafts/<editor>, authored by the editor |
| Publishes | One commit on the production branch with the selected entries, authored by the editor |
| Submits for review | A branch and a pull request; nothing changes on production until it is merged |
| Discards | The draft branch goes back to the published version of those entries |
Two things are guarded for you:
- A save from a stale page is refused. If the entry changed since the editor opened it (another tab, another editor, an agent over MCP), Studio says so and lets them load the newer version or keep theirs.
- Publishing never silently overwrites someone else. If an entry was published by someone else after the editor started their draft, Studio asks them to keep their version or take the published one.
1. Give Studio access to the repository
Pick one.
A GitHub App (recommended for a team). Create an app under your organisation's Developer settings with these repository permissions: Contents read and write, Pull requests read and write. Install it on the repository. Then set:
| Variable | Value |
|---|---|
GRAFT_GITHUB_APP_ID | The app's ID |
GRAFT_GITHUB_APP_PRIVATE_KEY | The private key you generated, as PEM. Literal \n sequences are accepted |
GRAFT_GITHUB_APP_INSTALLATION_ID | Optional. Found automatically from the repository |
Studio exchanges the app's key for tokens that last an hour, so nothing is tied to one person's account.
A fine-grained personal access token (simplest). Give it access to the one
repository, with Contents and Pull requests set to read and write, and set
GRAFT_GITHUB_TOKEN.
Then, either way:
| Variable | Value |
|---|---|
GRAFT_GITHUB_REPO | owner/name |
GRAFT_GITHUB_BRANCH | The branch your site deploys from. Default main |
GRAFT_GITHUB_CONTENT_PATH | Where your content directory is in the repository. Default: your content directory relative to the project, usually content |
GRAFT_STUDIO_PUBLISH | commit (publishing commits to the branch) or pull-request. Default commit |
If the production branch is protected against direct pushes, use
pull-request.
Even with commit, some publishes open a pull request: deletions and renames,
and changes over 200 lines, so the riskiest changes get a second pair of eyes.
Studio says why. Change the rule with content.review in graft.config.ts,
including collections that are always reviewed, or turn it off with
review: false. See Configuration.
2. Let people sign in
Set GRAFT_STUDIO_SECRET to at least 32 random characters
(openssl rand -base64 48). Sign-in is off until it is set. Studio signs
GitHub sessions, single sign-on sessions for people without an account, and
command-line invite links with it, so changing it signs out everyone who came
in that way and voids those links. Account sessions live in your database and
are not affected.
Studio accounts. People sign in with an email and a password, kept in your
database. The first time anyone opens Studio, it asks for the owner account.
On a server anyone can reach, it also asks for a setup code, so only whoever
deployed the site can claim it. graft serve prints the code when it starts,
and graft studio setup-code prints it from the site's settings.
The owner invites everyone else from Settings, People: an email and a role make a link to pass on. Studio sends no email, and shows each link once. The same list changes roles, makes a link to set a new password for someone who forgot theirs, and removes access, which signs that person out at once. Nobody can remove their own access, and the last admin can't be made anything else. Five wrong passwords in a row lock an account for 15 minutes.
Sign in with GitHub. Create an OAuth app (Developer settings, OAuth Apps)
with the callback URL https://<your-site>/api/studio/v1/auth/github/callback,
and set:
| Variable | Value |
|---|---|
GRAFT_GITHUB_CLIENT_ID | The OAuth app's client ID |
GRAFT_GITHUB_CLIENT_SECRET | Its client secret |
GRAFT_STUDIO_URL | The public origin, such as https://shop.example.com |
Someone you invited in Settings, People signs in with GitHub as their Studio account, with its role, when the invited email is one of the verified addresses on their GitHub account. Their first sign-in uses the invitation.
For anyone else, without a list, write access to the repository decides. Studio
asks GitHub with its own credential, which needs Metadata read on the
repository; every fine-grained token and GitHub App has it. If GitHub refuses,
the sign-in screen says access couldn't be checked and the server log says why.
To choose who can sign in with GitHub, and with which role, set
GRAFT_STUDIO_EDITORS:
GRAFT_STUDIO_EDITORS="ana@shop.example.com=admin, octocat, ben@shop.example.com=contributor"
Sign in with Google. In the Google Cloud console, open APIs & Services,
Credentials, and create an OAuth client ID of type Web application, with the
authorized redirect URI
https://<your-site>/api/studio/v1/auth/google/callback. Then set:
| Variable | Value |
|---|---|
GRAFT_GOOGLE_CLIENT_ID | The OAuth client's ID |
GRAFT_GOOGLE_CLIENT_SECRET | Its client secret |
GRAFT_STUDIO_URL | The public origin, such as https://shop.example.com |
Google sign-in follows the rules for single sign-on below: invite someone's Google email in Settings, People, and they press "Continue with Google".
Single sign-on with another provider (OIDC). For a provider your team
already uses: Okta, Microsoft Entra ID, Keycloak, your app's own Better Auth
and others. Register Studio with the provider as a web application whose
redirect URI is https://<your-site>/api/studio/v1/auth/oidc/callback, then
set:
| Variable | Value |
|---|---|
GRAFT_OIDC_ISSUER | The provider's issuer URL, the one its tokens carry in iss. Studio reads <issuer>/.well-known/openid-configuration |
GRAFT_OIDC_CLIENT_ID | The client ID the provider gave Studio |
GRAFT_OIDC_CLIENT_SECRET | Its client secret |
GRAFT_OIDC_NAME | Optional. The provider's name on the button, as in "Continue with Okta". Default "Continue with single sign-on" |
GRAFT_STUDIO_URL | The public origin, such as https://shop.example.com |
Set the first three together. Studio refuses to start with only some of them, and with an issuer that isn't https (plain http works only on localhost).
With Google or another provider, the provider says who someone is; Studio decides whether they get in. It reads the person's email from the ID token, or from the provider's userinfo endpoint, and only an email the provider marks verified counts. Then:
- Someone with a Studio account under that email signs in as that account, with its role. Removing them in Settings, People keeps them out, whatever the provider says.
- Someone invited from Settings, People gets their account on their first single sign-on, with the role they were invited with. To add a person, invite their email and tell them to press the button; there is no link to pass on and no password to choose, and Settings shows them no password to change. If they want one later, an admin makes them a link to set it.
- Someone whose verified email is at a domain in
GRAFT_STUDIO_AUTO_JOINjoins on their own, with the domain's role. See below. - Someone without an account or an invitation gets in only if
GRAFT_STUDIO_EDITORSlists their email, with the role it gives. - Anyone else is refused, and the sign-in screen says to ask for an invitation.
Let a whole domain join. To skip inviting everyone at your company, name its email domain, with the role new people get:
GRAFT_STUDIO_AUTO_JOIN="shop.example.com=contributor, agency.example=viewer"
The first time someone signs in with GitHub, Google or single sign-on and has
a verified email at one of those domains, Studio makes them an account with
that role. The role defaults to contributor. They show up in Settings,
People like anyone invited, where an admin can change their role or remove
them, and someone removed stays out. People also lists each domain that joins
on its own. A few things are refused so the door stays narrow:
- Admin. Nobody joins as an admin on their own; invite admins.
- Free mail providers such as gmail.com or outlook.com, where anyone can make an address.
- Joining before the owner exists. The first visit still creates the owner.
GitHub counts any verified address on someone's GitHub account, so a person whose work email is a second address there still joins.
Studio uses the authorization code flow with PKCE, and checks the ID token's signature, issuer, audience, expiry and nonce. If a sign-in fails, the screen says it didn't complete and the server log says why.
To sign in with your app's Better Auth, add its OAuth provider plugin (Better Auth 1.7 replaced the old OIDC provider plugin with it):
import { oauthProvider } from "@better-auth/oauth-provider";
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";
export const auth = betterAuth({
// ...your database and sign-in methods, as before
disabledPaths: ["/token"],
plugins: [jwt(), oauthProvider({ loginPage: "/sign-in", consentPage: "/consent" })],
});
Register Studio once, as a signed-in user of your app. skip_consent spares
your team a consent screen for your own tool:
const studio = await auth.api.adminCreateOAuthClient({
headers, // the signed-in user's request headers
body: {
client_name: "Graft Studio",
redirect_uris: ["https://shop.example.com/api/studio/v1/auth/oidc/callback"],
skip_consent: true,
},
});
Put studio.client_id and studio.client_secret in GRAFT_OIDC_CLIENT_ID
and GRAFT_OIDC_CLIENT_SECRET. The issuer is your app's Better Auth base URL,
such as https://shop.example.com/api/auth. Better Auth only lets a client
registered as application_type: "native" use an http redirect on localhost,
so register that way to try it out before Studio is on https.
Command-line invite links sign someone in without an account:
graft studio invite ana@shop.example.com --name "Ana Lima" --role editor
This prints a link that signs Ana in. It lasts seven days (--days sets 1 to
30). Send it privately: anyone with the link can use it until it expires.
Commits from an invited editor carry their name and email. An invite link
grants the role it was made with on its own: GRAFT_STUDIO_EDITORS applies to
GitHub, Google and single sign-on only, so an invited person does not need
to be on that list.
Roles
| Role | Can |
|---|---|
viewer | Read everything |
contributor | Draft changes. Publishing opens a pull request |
editor | Draft and publish |
admin | Everything an editor can, approve or deny actions waiting for a person, and manage people |
3. Deploy
Studio is served by graft serve --studio, the same server that answers
/api/fn, /api/mcp and the content API. Run it wherever you run Node: the
container image, Fly, Railway or Render. The content directory
can be read-only; nothing writes to it.
GRAFT_STUDIO=1 graft serve --host 0.0.0.0
Studio is then at https://<your-site>/studio. Sign-in needs that https
address: the session cookie is Secure, so a browser drops it on plain http
anywhere but localhost, and Studio says so instead of showing the form. To try
sign-in without https, open Studio at http://localhost on the machine that
runs it.
Set GRAFT_DEPLOYED_SHA to the
commit the deployment was built from, if your host does not already provide
one (Vercel, Render and Railway do). Studio uses it to show entries published
since the build, before the redeploy lands.
examples/shop in the repository is a product catalog set up this way.
4. Publish without a developer
When an editor publishes, the production branch moves and your host rebuilds
the site from it, running graft compile as part of the build like any other
deploy. There is no separate content pipeline to keep in sync.
Agents use the same drafts
On a server writing to GitHub, MCP's write_content and delete_content save
to the connection's own draft branch, and list_drafts, publish_drafts and
discard_drafts do what Studio's Publish sheet does. An agent opens a pull
request unless its token carries content:publish. See
MCP tools.
Give each agent its own token from Developer, Agents in Studio. Pick
Read, Write drafts or Publish; only Publish carries content:publish. The
agent sends the token to /api/mcp as Authorization: Bearer graft_at_….
Revoke it there and the agent is refused from its next request. See
Studio, Agents.
The same token works from a terminal or a CI job. graft login <url> saves
it, then graft content commands with --remote, and graft api, act on the
hosted site through the same endpoints. See
Work with a hosted site.
To rebuild or refresh the site after a publish, add a deploy hook under Settings, Deploy hooks. It is called after a publish of content in the database, and after an agent's commit to the production branch. See Studio, Site and deploy hooks.
Check it works
- Open
/studio. You see the sign-in screen. - Sign in, open an entry and change a field. The bar says
"Unpublished changes", and a branch called
graft-studio/drafts/<you>appears on GitHub with your commit. - Press Publish. One commit appears on the production branch, authored by you, and your draft branch no longer holds changes.
- Your host redeploys, and the change is live.
examples/shop/scripts/e2e-hosted.mjs runs the same steps against a stand-in
for GitHub, with the content directory read-only, and fails loudly if any step
does not hold.