# Graft — full documentation > Graft is an open-source CMS that keeps content as Markdown files in Git, checks every file against a TypeScript schema, and gives your app fully typed reads. AI agents can edit the same content through the Model Context Protocol (MCP), within limits you set. # What is Graft > An open-source CMS that keeps your content in Git, types it with your schema, and lets AI agents edit it safely. *What Graft does, how it works, and where to start reading.* Most sites need content that someone other than the developer edits: pages, docs, product listings. The usual answer is a hosted CMS, which moves that content out of your repository and into someone else's database. You lose diffs, review, and branches, and your app reads it back through an API with no types. Graft keeps the content in your repository as Markdown files. It checks every file against a schema you write in TypeScript, builds an index your app can query, and gives your components fully typed results. AI agents can edit the same files through the Model Context Protocol (MCP), the open standard that lets agents use tools, within limits you set. > **Beta.** > These docs describe `1.0.0-beta.x`. The overall design is settled. Details > still change between betas, and every change is listed in the changelog. ## How it works ```text ┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐ │ Markdown in Git │ ───► │ graft compile │ ───► │ Typed reads in app │ │ content/pages/*.mdx │ │ check + build index │ │ Next.js, Astro, React │ └────────────────────────┘ └────────────────────────┘ └────────────────────────┘ ▲ │ │ │ └─────────── You (editor, Git, CLI) and agents (MCP) ────────────┘ ``` 1. **You write content as files.** Markdown, or MDX (Markdown that can include components), in your project's `content/` folder. You commit, branch, and review it like code. 2. **`graft compile` checks it and builds an index.** Every file is checked against your schema, and a bad file stops the compile with a message saying what to fix. The index is a searchable copy of your content. Your files stay the source of truth, so the index can be deleted and rebuilt at any time. 3. **Your app reads typed content.** Graft has a package for each major framework. A read returns the document with types taken from your schema, so your editor knows every field. 4. **Agents work the same loop.** An agent connected over MCP can draft pages, search content, and read your schema. It uses the same operations you do, so the same checks apply. ## Static or Postgres? The index lives in one of two storage engines. You write and read content the same way on both. | Storage engine | Use it for | What you run | | :--- | :--- | :--- | | **Static** (what `graft init` sets up) | Blogs, documentation, marketing sites, portfolios | Nothing. The index is a SQLite file built by `graft compile`. | | **Postgres** | Sites where users write data at runtime, such as form submissions, comments, and orders. Also preview branches. | A Postgres database. | Start with static. Switching later is one line in `graft.config.ts`, and [Storage engines](/docs/tiers) explains when to switch and how. ## How these docs are organized - **Get started** explains what Graft is and walks you through a first project. - **Frameworks** has one guide per framework, from install to a rendered page. - **Content** covers the schema, reading, search, assets, and the editing UI. - **Runtime data** covers data your users write, such as form submissions and orders, and who may write it. This needs the Postgres engine. - **Agents & MCP** covers connecting agents and every tool they can call. - **Deploy & operate** covers hosting, security, previews, and migrations. - **Reference** lists every command, setting, API, and error code, plus a [glossary](/docs/glossary) of Graft's terms. You should be comfortable with TypeScript and with one of the frameworks below. You do not need to know Zod, MCP, or Postgres before you start. Each one is introduced where you first need it. ## Where to go next New to Graft? Read [Getting started](/docs/getting-started) next. It takes about ten minutes and ends with your content rendered in your app. If you already know what you are building, jump straight to it: - [Next.js](/docs/next): Typed reads in Server Components, MDX rendering, and cache revalidation. - [React in the browser](/docs/react): Hooks that read content over HTTP. No database in the client. - [Astro](/docs/astro): Read content in page frontmatter and ship zero client JavaScript. - [SvelteKit](/docs/sveltekit): Read in server load functions and mount Graft on a server route. - [Securing a deployment](/docs/hardening): Give your app a database role that cannot approve its own destructive changes. - [Connecting an agent](/docs/connecting-agents): Point Claude Code, Cursor, or any MCP client at your project. --- # Getting started > Create a Graft project, compile content, and read it back typed. *Go from an empty folder to your content on a page in about 10 minutes.* > **Starting from nothing?** > > `npm create graft@latest my-site` makes a whole site in one step: a template, > a database, Studio and a Next.js app, with example content. This guide adds > Graft to a project you already have. > In this guide you will: - Set up Graft in a project with `graft init` - Compile your content, which checks each file and builds the index your app reads from - Read a document in your app, with full TypeScript types ## Before you start You need **Node.js 22.16 or newer**. Graft's static index uses the `node:sqlite` module that ships with it. You do not need a database or a cloud account. > **Prompt for a coding agent:** > Add Graft to this project following > https://graft.page/docs/getting-started.md. Use the static engine, compile > the sample content, and render the `home` page. ## Set up your first project Pick the tab that matches your project. Each one runs the same three steps: set up, compile, read. The Next.js and Astro paths end with a rendered page. The plain TypeScript path prints the document in your terminal, with no framework involved. Other frameworks are listed [after the tabs](#your-framework). **Next.js:** ### Next.js (App Router) Goal: render a compiled document in a Server Component. **Install the Next.js SDK and CLI** The SDK supports Next.js 15 and 16. Check yours with `npx next --version`. Releases before 15.5.24 on 15, or 16.3.3 on 16, have critical security advisories, so upgrade if you can: see [Check your Next.js version](/docs/next#check-your-nextjs-version). Next.js 14 is not supported, because it will not get fixes for them. These steps use the App Router; the [Next.js guide](/docs/next) covers the Pages Router. ```sh npm i @usegraft/sdk-next @usegraft/db npm i -D @usegraft/cli ``` **Initialize Graft** ```sh npx @usegraft/cli@latest init ``` This scaffolds `graft.config.ts`, creates sample content in `content/pages/home.mdx`, and sets up the static index. **Compile your content** ```sh npx graft compile ``` This validates your markdown files against the schema and writes an embedded SQLite index to `.graft/index.db`. The index is derived state: your files in Git stay authoritative, and recompiling rebuilds it at any time. **Create the Graft client** The client is the object your pages read through. Create it once, in its own module, so every page shares it: `lib/graft.ts`: ```ts import { openStaticIndex } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-next"; import { collections } from "@/graft.config"; export const graft = createGraft({ index: await openStaticIndex(".graft/index.db"), collections, }); ``` It lives outside `app/` on purpose. A Next.js page file may only export the page and its route options, so a client exported from `app/page.tsx` fails the build's type check. **Read content in a Server Component** Replace `app/page.tsx` to read the sample page and render it: `app/page.tsx`: ```tsx import { MdxBody } from "@usegraft/sdk-next"; import { notFound } from "next/navigation"; import { graft } from "@/lib/graft"; export default async function Page() { const home = await graft.getContent("pages", "home"); if (!home) notFound(); return (

{home.data.title}

{home.data.tagline}

); } ``` `home.data` is typed from your schema: hover `title` in your editor and you will see `string`. `MdxBody` renders the Markdown body on the server. Start your dev server and open `http://localhost:3000`: ```sh npm run dev ``` You should see the heading **Hello, Graft**, its tagline, and the body text from `content/pages/home.mdx`. Edit that file, run `npx graft compile` again, and refresh to see the change. **Plain TypeScript:** ### Plain TypeScript (No Framework) Goal: read a compiled document from a script, with no framework involved. **Scaffold a project folder** ```sh mkdir my-graft-site && cd my-graft-site npm init -y npm pkg set type=module npm i @usegraft/db @usegraft/sdk-core npm i -D @usegraft/cli tsx typescript @types/node npx @usegraft/cli@latest init ``` `type=module` makes the project an ES module, so the script in the last step can use `await` at the top level. **Compile content** ```sh npx graft compile ``` This compiles `content/pages/home.mdx` into `.graft/index.db`. **Read and verify output** Create `read.ts`: `read.ts`: ```ts import { openStaticIndex } from "@usegraft/db"; import { createClient } from "@usegraft/sdk-core"; import { collections } from "./graft.config"; const index = await openStaticIndex(".graft/index.db"); const graft = createClient({ index, collections }); const home = await graft.getDocument("pages", "home"); console.log("Title:", home?.data.title); console.log("Tagline:", home?.data.tagline); ``` Without a framework you use `@usegraft/sdk-core` directly, whose read method is `getDocument`. The framework packages wrap the same call and name it `getContent`. Both return the same typed document. Run the script: ```sh npx tsx read.ts ``` You will see the values from `content/pages/home.mdx`: ```text Title: Hello, Graft Tagline: Content lives in git; the index is a file. ``` **Astro:** ### In your Astro project Goal: render a compiled document in a zero-JavaScript Astro page. **Install the Astro SDK and CLI** ```sh npm i @usegraft/sdk-astro @usegraft/db npm i -D @usegraft/cli ``` **Initialize Graft** ```sh npx @usegraft/cli@latest init ``` **Compile your content** ```sh npx graft compile ``` **Read content in an Astro page** In `src/pages/index.astro`: ```astro --- import { openStaticIndex } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-astro"; import { collections } from "../../graft.config"; const graft = createGraft({ index: await openStaticIndex(".graft/index.db"), collections, }); const home = await graft.getContent("pages", "home"); ---

{home?.data.title}

{home?.data.tagline}

``` ### Your framework Every framework reads the same index with the same typed calls. Pick your guide: | Framework | Package | Guide | | :--- | :--- | :--- | | Next.js (App Router or Pages Router) | `@usegraft/sdk-next` | [Next.js](/docs/next) | | Astro | `@usegraft/sdk-astro` | [Astro](/docs/astro) | | SvelteKit | `@usegraft/sdk-sveltekit` | [SvelteKit](/docs/sveltekit) | | TanStack Start | `@usegraft/sdk-tanstack-start` | [TanStack Start](/docs/tanstack-start) | | React Router (framework mode) | `@usegraft/sdk-react-router` | [React Router](/docs/react-router) | | React in the browser | `@usegraft/sdk-react` | [React](/docs/react) | | Plain Node or TypeScript | `@usegraft/sdk-core` | [SDK reference](/docs/sdk-reference) | ## Add your own content Each document is a file at `content//.mdx`: - A **collection** is a named group of documents, like `pages`. Its fields are declared in `graft.config.ts`. - The **slug** is the document's name inside the collection. `content/pages/about.mdx` has the slug `about`. - **Frontmatter** is the block between the `---` lines at the top. Its fields must match the collection's schema, or the compile stops and says which field is wrong. - The rest of the file is the body, in Markdown or MDX (Markdown that can include components). Create `content/pages/about.mdx`: `content/pages/about.mdx`: ```mdx --- title: About us tagline: We build with code and AI agents. --- Welcome! This page lives in Git, next to your code. ``` Then compile again, or leave `graft dev` running so it recompiles whenever a file changes: ```sh npx graft compile # compile once npx graft dev # recompile on every change ``` Read it the same way you read `home`: `graft.getContent("pages", "about")`. ## When you need Postgres The project you just made uses the static engine, which needs nothing running. Move to Postgres when users of your site write data at runtime, such as comments, orders, or form submissions. Git cannot rebuild that data, so it needs a database. Preview branches need Postgres too. Switching is one line in `graft.config.ts` (`export const index = "postgres"`) plus a database URL. [Storage engines](/docs/tiers) walks you through it. ## If something goes wrong These are the errors people hit most often while getting started: | Error code | What it means | What to do | | :--- | :--- | :--- | | `STATIC_INDEX_NOT_FOUND` | Your app read the index before it was compiled | Run `npx graft compile`, then start your app | | `STATIC_INDEX_UNSUPPORTED` | Your Node.js version is older than 22.16 | Upgrade Node.js | | `SCHEMA_VALIDATION_FAILED` | A file's frontmatter does not match its schema | The message names the file and field. Fix it and compile again | | `NEEDS_DATABASE` | Your config uses a feature that needs Postgres | See [When you need Postgres](#when-you-need-postgres) | Every error also prints a `fix` line with the exact next step. The [Error reference](/docs/errors) explains all of them. ## Next steps You have content in Git, a compiled index, and a typed read. From here: - [How Graft works](/docs/the-model): What compiling does, and why your files stay the source of truth. - [Schema](/docs/schema): Describe your own collections and fields. - [Your framework guide](/docs/next): Rendering MDX, caching, and route handlers, starting with Next.js. - [Connecting an agent](/docs/connecting-agents): Let Claude Code, Cursor, or any MCP client edit your content. --- # How Graft works > Where your content lives, what compiling does, and the one schema that checks everything. *Four ideas that explain the rest of the docs.* After [Getting started](/docs/getting-started) you have a project that compiles and a page that reads content. This page explains what happened, so the rest of the docs make sense. It introduces four ideas, one at a time: 1. Your files are the source of truth. 2. Content and user data are kept apart. 3. One schema checks everything. 4. Errors tell you what to do next. ## Your files are the source of truth You write content as files in Git. Your app does not read those files directly, because searching and filtering hundreds of files on every request would be slow. Instead, `graft compile` reads them, checks each one, and builds an **index**: a copy of your content organized for fast queries. The index is always a copy. If it is lost, deleted, or out of date, run `graft compile` and it is rebuilt from your files. This is why Graft calls the index **derived**: everything in it comes from Git. > **The rule.** > If the index and your files ever disagree, your files are right. Recompile. Compiling only changes what changed. Graft compares each file with what the index already holds and updates just the documents that differ, so unchanged documents keep their timestamps. The list of documents a compile added, changed, or removed is called a **ChangeSet**. Framework packages use it to refresh only the cached pages that changed. ## Content and user data are kept apart Some data cannot live in files. When a visitor submits a form, places an order, or leaves a comment, that data is created at runtime, by your users, and there is no file in Git to rebuild it from. Graft calls this **operational data**. The two kinds are stored and written differently: | | Content | Operational data | | --- | --- | --- | | Examples | pages, docs, products | form submissions, orders, comments | | Source of truth | files in Git | rows in Postgres | | Who writes it | you and agents, by editing files | your users, through typed functions | | How you declare it | a normal collection | a collection with `authority: "db-authoritative"` | Operational data needs the [Postgres engine](/docs/tiers), because that is where its rows live. If a collection is declared one way and written the other, Graft stops with `AUTHORITY_MISMATCH` and tells you which collection is wrong. [Operational data](/docs/operational-data) covers it in full. ## One schema checks everything You describe each collection once, in `graft.config.ts`. That one description is used everywhere data crosses a boundary: - `graft compile` checks every file's frontmatter against it. - Typed functions check their inputs against the `input` schema each one declares. That can reuse a collection's fields (`input: submissions.fields`) or describe something else entirely, so a collection's rules apply to a function only when its input uses them. - Reads check documents on the way out. The schema is built on [Zod](https://zod.dev), a TypeScript validation library, so you get its types for free. There is no code generation step: `getContent("pages", "home")` is typed by the same object that checked the file. [Schema](/docs/schema) shows how to write one. ## Errors tell you what to do next Every Graft error has a `code`, a `message` saying what happened, and a `fix` naming the next step. The `fix` is written so that both you and an agent can act on it without searching the docs: ```json { "error": "INDEX_OWNERSHIP", "message": "Refusing to project: this would remove every document in collection(s) \"products\" …", "fix": "Each Graft project needs its own database (or branch): point DATABASE_URL at …" } ``` The [Error reference](/docs/errors) lists every code. ## One database per project On the Postgres engine, compiling makes the index match your current schema exactly. That includes removing collections your schema no longer declares. If two projects pointed at the same database, each compile would delete the other project's content. Graft prevents this. If a compile would remove collections your schema does not know about, it stops with `INDEX_OWNERSHIP` and writes nothing. Give each project its own database, or its own [branch](/docs/branching-previews) of one. If you renamed or deleted a collection on purpose, run the compile with `--prune-unknown` to allow the removal. ## Next steps - [Storage engines](/docs/tiers) helps you choose between static and Postgres. - [Schema](/docs/schema) shows how to describe your collections. - [Reading content](/docs/reading-content) covers reads in every framework. --- # Set up a site for someone > For a coding agent helping a person who doesn't code: what to ask, what to run, and how to explain each step. *Written for coding agents. A person asked you for a website they can edit themselves; this is how to give them one.* You are setting up a site for someone who may never open a terminal. They should end up with a site that runs, an editor they can use in a browser, and an idea of what happens next. Keep every message short and free of jargon. When you must use a term, explain it once in a few words. ## 1. Ask three questions Ask in plain words, all at once, and accept "I don't know" as an answer. | Question | Their answer | Flag | | --- | --- | --- | | What is the site for? | A blog, documentation, a shop or menu, a portfolio, or something else | `--template blog`, `docs`, `shop`, `portfolio` or `blank` | | Who changes the content, and how? | "Me, in a browser" (the usual answer), or "developers, in code" | `--edit online` or `--edit code` | | Do you already have a database? | Almost always "no" or "I don't know" | `--database new`, or `--database ` | When in doubt, choose `blog`, `online` and `new`. Editing online keeps the content in a database, and a change goes live as soon as it is published, with no rebuild. ## 2. Make the site Run one command with their answers. `--yes` takes the defaults for anything you didn't ask. `Terminal`: ```sh npm create graft@latest bakery -- --template shop --edit online --database new --yes ``` It writes the site, makes a free database, installs everything, loads example content and prints what to run next. The folder gets an `AGENTS.md` that tells the next agent how this site works. Then tell the person what happened, in about this many words: > Your site is set up with example content. I also made a free database for > it. It's deleted after three days unless you keep it, which takes one sign-up > and costs nothing. I'll walk you through that next. ## 3. Keep the free database The database `--database new` makes is free and needs no account, but it is deleted after 72 hours unless someone claims it, and until then it holds at most 100 MB. Claiming it moves it into a free account the person owns. `Terminal`: ```sh npx graft db claim ``` It prints a link. Give the link to the person and ask them to open it and sign up or sign in. The command waits until they have. Claiming changes the database's password, so it then asks for the new connection string: the person copies it from their database dashboard (Connect), and you paste it in. Never ask them to paste a password into a chat you don't control; if they must, tell them to change it afterwards. ## 4. Show them the site and the editor `Terminal`: ```sh npm run dev # the site, at http://localhost:3000 npm run studio # Studio, the editor, at http://localhost:4983 ``` Studio is where they will work. On their own computer it needs no sign-in. Show them one thing: open a page, change a word, press Publish, and reload the site. ## 5. Check where things stand Run `npx graft doctor` (or call the MCP tool `setup_status` on a local `graft mcp`) whenever you are unsure. It checks the database, its schema, the content, the index and Studio sign-in, and ends with the one thing to do next. Do that thing, then run it again. ## 6. Put it online The site needs a host that runs Node.js, with two settings copied from `.env`: | Setting | What it is | | --- | --- | | `DATABASE_URL` | Where the content lives | | `GRAFT_STUDIO_SECRET` | What keeps people signed in to Studio | Studio runs with `graft serve --studio`, wherever the site's settings are. The first person to open `/studio` online creates the owner account. On a server anyone can reach, it asks for a setup code first, so nobody else can claim it: `graft serve` prints the code when it starts, and `npx graft studio setup-code` prints it from the settings. The owner then invites everyone else from **Settings, People**. See [Host Studio for your team](/docs/studio-hosting). ## Words to use | Instead of | Say | | --- | --- | | Repository, commit, push | "The site's files", "save a version" | | Database, Postgres | "Where the content is stored" | | Environment variable | "A setting" | | Deploy | "Put it online" | | Migration | Don't mention it: `graft create` and `graft doctor` handle it | ## Rules - Every Graft error has a `fix`. Do what it says before trying anything else. - Never commit `.env`, and never paste its contents anywhere public. - Ask before you publish anything on the person's behalf. A publish is live immediately. --- # Storage engines > Where your content index lives: a SQLite file with nothing to run, or Postgres when users write data at runtime. *Pick static or Postgres, and know when to switch.* When you compile, Graft builds an **index**: a searchable copy of your content that your app reads from. The storage engine decides where that index lives. It does not change how you write content, how you read it, or what your schema looks like. There are two engines: - **Static.** The index is a single SQLite file, `.graft/index.db`, built by `graft compile`. You run no database and no services. - **Postgres.** The index lives in a Postgres database. You need this engine when your users write data at runtime, such as form submissions, comments, or orders, because that data cannot be rebuilt from files in Git. Start static. Move to Postgres the day you need one of the features in [What only Postgres can do](#what-only-postgres-can-do). ## Which engine am I on? Look for the `index` export in `graft.config.ts`: `graft.config.ts`: ```ts export const index = "static"; // or "postgres" ``` `graft init` writes `"static"` for you. If a config has no `index` export at all, Graft uses Postgres, so older projects keep working. Set it explicitly and you never have to remember that rule. ## What works on both > Works on both storage engines, static and Postgres. Everything you need to write, compile, and read content: - `graft init`, `graft compile`, and `graft dev` - Typed reads through every framework package - Full-text search, with ranking and highlighted snippets - Agents over MCP with `graft mcp`, including document resources and prompts - Uploading assets with `graft asset put`, once storage credentials are set - Editing content in Studio with `graft studio`: each save updates `.graft/index.db` ([Studio](/docs/studio#without-a-database)) ## What only Postgres can do > Needs the Postgres storage engine. These features store data that Git cannot rebuild, so they need a database: - **Runtime data**: collections your users write to, declared with `authority: "db-authoritative"` ([Operational data](/docs/operational-data)) - **Typed functions** that write that data, served at `POST /api/fn/` ([Functions & access](/docs/functions-and-access)) - **The audit log, rate limits, and human approvals** for agent actions ([Audit & approvals](/docs/audit-and-approvals)) - **Preview branches** and `graft merge` ([Branching & previews](/docs/branching-previews)) - **Studio's team features**: accounts and sign-in for a hosted Studio, approvals, the compile history, the media library, agent tokens, and `graft serve` ([Studio](/docs/studio), [Host Studio for your team](/docs/studio-hosting)) If a static project declares one of these, Graft refuses to load the config and tells you which declaration needs Postgres: ```text graft.config.ts uses the static index, but declares typed function(s) "submitContact" — those live in Postgres, not in the compiled artifact. ``` That error is `NEEDS_DATABASE`. Its `fix` lists the steps in [Moving to Postgres](#moving-to-postgres). ## Working with the static index file `graft compile` writes `.graft/index.db`. A few things to know about it: - **It is generated, so keep it out of Git.** `graft init` adds it to `.gitignore`. Rebuild it as part of your build command: `graft compile && next build`. - **A failed compile never leaves a half-written file.** Each compile builds a new file and swaps it in only when it is complete. - **It works on a read-only filesystem.** Your app opens it read-only. - **It needs Node 22.16 or newer**, which ships `node:sqlite`. There is no native dependency to install. - **Each Git checkout has its own file.** That is how branches work on the static engine: a different checkout compiles a different index. The SDK's `branch` option is accepted and has no effect here. Your app opens the file and reads from it: `lib/graft.ts` (static engine): ```ts import { openStaticIndex } from "@usegraft/db"; import { createClient } from "@usegraft/sdk-core"; import { collections } from "../graft.config"; const index = await openStaticIndex(".graft/index.db"); export const graft = createClient({ index, collections }); ``` If your app reads before the first compile, you get `STATIC_INDEX_NOT_FOUND`. If your Node version is too old, you get `STATIC_INDEX_UNSUPPORTED`. ## Agents on the static engine Agents work on both engines. `graft mcp` serves a static project, an agent's `write_content` call checks the file and recompiles it the same way, and `search_content` searches the static index. Tools that need Postgres stay listed, so an agent can see they exist. Calling one returns `NEEDS_DATABASE` and the steps to upgrade. Two tools refuse on purpose: | Tool | Why it refuses on the static engine | | --- | --- | | `delete_content` | Deletes wait for a human approval, and approvals are stored in Postgres. Allowing the delete without that check would skip the approval. On the static engine, delete the file and recompile. Git history is your undo. | | `list_compilations` | The static file keeps only the last 50 compile runs. Returning a partial history under the same tool name could mislead an agent, so the tool reports that it needs Postgres instead. | ## Moving to Postgres Four steps, in this order. **Point at a database** Set `DATABASE_URL` in a `.env` file in your project, or in any parent folder: ```sh DATABASE_URL=postgres://… ``` **Switch the index** `graft.config.ts`: ```ts export const index = "postgres"; ``` **Create Graft's tables** ```sh graft db migrate ``` This creates the tables Graft needs. The SQL ships inside `@usegraft/db`, so there is nothing else to install. It is safe to run again: it only adds what is missing. Pass `--dry-run` to see what it would create first. **Compile again** ```sh graft compile ``` Then give your client the database instead of the file. With a framework package, pass `db` where you passed `index` (Next.js shown): `lib/graft.ts` (Postgres engine): ```ts import { createDb } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-next"; import { collections } from "@/graft.config"; export const graft = createGraft({ db: createDb(process.env.DATABASE_URL!).db, collections, }); ``` Without a framework, use `createDbClient` from `@usegraft/sdk-core/db` in the same way. ## Moving back to static Set `index = "static"` again. The config refuses to load while it still declares typed functions or db-authoritative collections, so remove those first. If one came from a `graft add` primitive, delete it from `graft/`. Switching back does not delete anything from Postgres. Rows your users wrote stay in the database. ## One database per project On the Postgres engine, each project needs its own database, or its own branch of one. Compiling makes the index match your current schema exactly, so two projects sharing a database would each delete the other's content. Graft stops the compile with `INDEX_OWNERSHIP` before that can happen. See [How Graft works](/docs/the-model#one-database-per-project). The static engine cannot hit this, because each project has its own file. ## Next steps - [Getting started](/docs/getting-started) runs the static setup end to end. - [Operational data](/docs/operational-data) shows what to build once you move to Postgres. - [Configuration](/docs/configuration) lists every environment variable. - [Deploy](/docs/deploy) covers hosting either engine. --- # Next.js > Read typed content in Next.js 15 or 16, with the App Router or the Pages Router, render MDX, and refresh only the pages that changed. *Render Graft content in a Next.js project, from install to cache refresh.* This guide adds Graft to a Next.js project. By the end, your pages read typed content, render its Markdown with your own components, and refresh only the pages whose content changed. The steps use the App Router. If your project uses the Pages Router, follow them up to the client, then [use the Pages Router](#use-the-pages-router). **Before you start**, you need a Next.js 15 or 16 project and a `graft.config.ts` with compiled content. If you have not got those yet, run through [Getting started](/docs/getting-started) first. It takes about ten minutes. Still on Next.js 14? Read [Run Graft on Next.js 14](#run-graft-on-nextjs-14) first. What `@usegraft/sdk-next` gives you: - **Typed reads.** Every result is typed from your schema, in Server Components, route handlers, `getStaticProps` and `getServerSideProps`. - **One read per document per request.** If a layout and a page read the same document, Graft reads it once, using `React.cache`. - **Markdown rendering on the server.** `` renders a document's body without sending an MDX parser to the browser. - **Targeted cache refresh.** `revalidateContent` refreshes only the pages whose content changed. > **Prompt for a coding agent:** > Add Graft to this Next.js app following https://graft.page/docs/next.md. Put > the client in lib/graft.ts and render the pages collection at > app/[slug]/page.tsx. ## Check your Next.js version Graft supports Next.js 15 and 16, with the App Router or the Pages Router. What differs between them: | | Next.js 15 | Next.js 16 | | --- | --- | --- | | App Router and Pages Router | Yes | Yes | | Cached reads you can refresh | `unstable_cache` | `'use cache'` and `cacheTag` | | `revalidateContent` | Yes | Yes | | `updateContent` | No | Yes | Graft does not refuse an older 15 or 16 release, but some are not safe to run. 15.5.24 and 16.3.3 are the first releases with fixes for the critical Next.js advisories published in 2026. On a release without those fixes, `withGraft` prints a warning when Next.js loads your config that names the release to upgrade to: ```text graft: Next.js 15.5.23 has known critical security advisories. Upgrade to 15.5.24 or later. ``` ## Install **npm:** ```sh npm i @usegraft/sdk-next @usegraft/db ``` **pnpm:** ```sh pnpm add @usegraft/sdk-next @usegraft/db ``` **bun:** ```sh bun add @usegraft/sdk-next @usegraft/db ``` ## Wrap your Next.js config Wrap your config with `withGraft`. It keeps `@usegraft/registry` out of the server bundle, because that package reads files from disk at runtime, and it writes the setting under the name your Next.js version reads. Plain content reads work without it, but the [MCP route](#serve-functions-and-mcp-from-your-app) needs it, so add it now: `next.config.ts`: ```ts import { withGraft } from "@usegraft/sdk-next/config"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { // your existing Next.js options }; export default withGraft(nextConfig); ``` ## Create the Graft client Create the client once, in a file only the server imports. Pick the version for your [storage engine](/docs/tiers). **Static:** `lib/graft.ts`: ```ts import { openStaticIndex } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-next"; import { collections } from "@/graft.config"; export const graft = createGraft({ index: await openStaticIndex(".graft/index.db"), collections, }); ``` **Postgres:** `lib/graft.ts`: ```ts import { createDb } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-next"; import { collections } from "@/graft.config"; export const { db } = createDb(process.env.DATABASE_URL!); export const graft = createGraft({ db, collections }); ``` The Postgres version also exports `db`, because the function and MCP routes below need it. In the App Router, every read goes through `React.cache`. If a layout and a page both read the same document during one request, the index is read once. The Pages Router reads in one data function per page, so there is nothing to share. ## Show a document A page reads its document by slug and renders the body with ``. `getContent` returns `null` for a slug that does not exist, so send that to `notFound()`: `app/[slug]/page.tsx`: ```tsx import { notFound } from "next/navigation"; import { MdxBody } from "@usegraft/sdk-next"; import { graft } from "@/lib/graft"; // Optional: build every page ahead of time. export async function generateStaticParams() { const pages = await graft.listContent("pages"); return pages.map((page) => ({ slug: page.slug })); } export default async function Page({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const page = await graft.getContent("pages", slug); if (!page) notFound(); return (

{page.data.title}

{page.data.tagline &&

{page.data.tagline}

}
); } ``` `page.data` is typed from the `pages` collection in your schema, so a misspelled field is a compile error, not an empty heading. ## Use your own components in Markdown Pass `components` to `` to render a tag in your content with your own React component. This content uses a `Callout`: `content/pages/home.mdx`: ```mdx Prices change on 1 March. ``` And this renders it: `components/document-body.tsx`: ```tsx import type { ReactNode } from "react"; import { MdxBody } from "@usegraft/sdk-next"; const components = { Callout: ({ title, children }: { title?: string; children: ReactNode }) => ( ), }; export function DocumentBody({ source }: { source: string }) { return ; } ``` > **Content cannot run code.** > By default `` refuses `{…}` expressions, `import`, and `export`, > because those would run as JavaScript on your server. Components with plain > string attributes, like the `Callout` above, are allowed. See > [MDX safety](/docs/mdx-safety). ## Use the Pages Router The Pages Router reads content in `getStaticProps` or `getServerSideProps` and renders in a component that also runs in the browser. Two things change from the App Router: how a body is rendered, and how pages refresh. | | App Router | Pages Router | | --- | --- | --- | | Where you read | Server Components and route handlers | `getStaticProps`, `getServerSideProps` and API routes | | Render a body | `` | `prepareMdx` in `getStaticProps`, then `` | | Refresh after a change | By tag, with `revalidateContent` | By path, with `res.revalidate` | ### Show a document `prepareMdx` compiles the body on the server, with the same safety rule as ``, and returns a string. `` renders that string, in the server render and in the browser. Import it from `@usegraft/sdk-next/mdx`, which carries no server code, so the page's browser bundle stays small: `pages/[slug].tsx`: ```tsx import type { GetStaticPaths, GetStaticProps } from "next"; import { prepareMdx } from "@usegraft/sdk-next"; import { MdxContent } from "@usegraft/sdk-next/mdx"; import { graft } from "@/lib/graft"; interface Props { title: string; tagline: string | null; code: string; } export const getStaticPaths: GetStaticPaths = async () => { const pages = await graft.listContent("pages"); return { paths: pages.map((page) => ({ params: { slug: page.slug } })), fallback: "blocking", }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { const page = await graft.getContent("pages", params?.slug ?? ""); if (!page) return { notFound: true }; return { props: { title: page.data.title, tagline: page.data.tagline ?? null, code: await prepareMdx(page.body), }, }; }; export default function Page({ title, tagline, code }: Props) { return (

{title}

{tagline &&

{tagline}

}
); } ``` Props must be JSON, so pass the fields the page shows rather than the whole document: its `updatedAt` is a `Date`, which Next.js refuses to serialize. Pass `components` to `` as you would to [``](#use-your-own-components-in-markdown). A body that uses `import`, `import()` or `import.meta` is refused with `INPUT_VALIDATION_FAILED`, even with `trust: "full"`, because the browser has no bundler to resolve it. Pass the component in `components` instead. A body that only mentions an import, in a code span or a fenced block, is prose and renders. > **MdxContent needs unsafe-eval in a Content Security Policy.** > `` runs the prepared code with `new Function`, in the server > render and again in the browser. A `script-src` without `'unsafe-eval'`, such > as the nonce-based policy Next.js suggests, blocks it in the browser, and the > body stops working after hydration. Add `'unsafe-eval'` to `script-src` on the > pages that use it, or render those pages with the App Router's ``, > which runs only on the server. With `trust: "full"`, a body's expressions run > in visitors' browsers as well as on the server. ### Refresh Pages Router pages > Needs the Postgres storage engine. The Pages Router caches whole pages, not reads, so a change refreshes the pages that show it, by path. Use this route instead of the App Router's in [step 2 below](#refresh-pages-when-content-changes), and point `GRAFT_REVALIDATE_URL` at it as step 3 describes: `pages/api/revalidate.ts`: ```ts import type { NextApiRequest, NextApiResponse } from "next"; const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string"); // The page that shows each changed document. A key is "collection/slug". function pathFor(key: string): string | null { const [collection, slug] = key.split("/"); return collection === "pages" && slug ? `/${slug}` : null; } export default async function handler(req: NextApiRequest, res: NextApiResponse) { const secret = process.env.GRAFT_WEBHOOK_SECRET; if (req.method !== "POST" || !secret || req.headers.authorization !== `Bearer ${secret}`) { return res.status(401).json({ error: "Unauthorized" }); } const changes = req.body?.changes; const keys = [changes?.added, changes?.changed, changes?.removed]; if (!keys.every(isStrings)) { return res.status(400).json({ error: "Send { branch, changes }." }); } const paths = keys.flat().flatMap((key) => pathFor(key) ?? []); await Promise.all(paths.map((path) => res.revalidate(path))); return res.json({ revalidated: paths }); } ``` Add a line to `pathFor` for each collection with its own page, and revalidate any page that lists a collection when a document in it changes. Step 1 below is not needed: the Pages Router has no cached reads to tag. ## Serve functions and MCP from your app > Needs the Postgres storage engine. Skip this section unless you need it. Mount these routes when you want to: - call your [typed functions](/docs/functions-and-access) at `POST /api/fn/`, or - let remote agents read and write content over MCP at `POST /api/mcp`. Both need the Postgres engine and three more packages: ```sh npm i @usegraft/core @usegraft/auth @usegraft/mcp ``` Both routes need to know who is calling. This resolver accepts one token from your environment, which is enough to try things out. A dev token never expires, so the resolver ignores it in production. [Auth](/docs/auth) shows how to accept signed tokens from your identity provider. `lib/actor.ts`: ```ts import { createActorResolver } from "@usegraft/auth"; // A dev token never expires, so it is accepted only in development. An unset // NODE_ENV counts as production, so a deploy that forgets it stays closed. const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined; export const actor = createActorResolver({ devTokens: devToken ? { [devToken]: { kind: "human", id: "owner", scopes: ["content:write", "submissions:admin"], }, } : undefined, }); ``` `app/api/fn/[name]/route.ts`: ```ts import { createFunctionsHandler } from "@usegraft/core"; import { functions } from "@/graft.config"; import { actor } from "@/lib/actor"; import { db } from "@/lib/graft"; export const POST = createFunctionsHandler({ db, functions, actor }); ``` `app/api/mcp/route.ts`: ```ts import { createGraftMcpHandler } from "@usegraft/mcp"; import { collections, functions } from "@/graft.config"; import { actor } from "@/lib/actor"; import { db } from "@/lib/graft"; export const POST = createGraftMcpHandler({ contentDir: "./content", db, collections, functions, actor, }); ``` A request without a valid token gets a `401`. Never mount the MCP route with `allowAnonymous: true` on a server that others can reach. For public, read-only docs, use the [docs MCP](/docs/docs-mcp) instead. ## Refresh pages when content changes > Needs the Postgres storage engine. On the static engine, content changes ship with a new deploy, so there is nothing to refresh. On Postgres, content can change while your app is running, from a local Studio or from an agent. A server that writes to GitHub ([Host Studio for your team](/docs/studio-hosting)) is different: saves are drafts. Publishing either commits to your production branch or opens a pull request, and your host rebuilds when the change reaches that branch. A local Studio refreshes the app after a save that changes content, once the webhook below (or an `onContentChange` hook) is set up, and its Publish only commits in your checkout, which you push yourself. This section makes Next.js refresh only the pages whose content changed. It takes two steps. How you cache reads depends on your version. Next.js 16 uses cached functions. Next.js 15 uses `unstable_cache`. The refresh route is the same on both. On the Pages Router, use [its own route](#refresh-pages-router-pages) instead. **Tag each cached read** Cache each read and tag it with `tagsFor`. Use these functions in your pages instead of calling `graft` directly. **Next.js 16:** Turn on cached functions in the config you wrapped with `withGraft`: `next.config.ts`: ```ts import { withGraft } from "@usegraft/sdk-next/config"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { // your existing Next.js options cacheComponents: true, }; export default withGraft(nextConfig); ``` Then wrap each read in a `'use cache'` function: `lib/content.ts`: ```ts import { cacheTag } from "next/cache"; import { tagsFor } from "@usegraft/sdk-next"; import { graft } from "@/lib/graft"; export async function getPage(slug: string) { "use cache"; cacheTag(...tagsFor("main", "pages", slug)); return graft.getContent("pages", slug); } export async function listPages() { "use cache"; cacheTag(...tagsFor("main", "pages")); return graft.listContent("pages"); } ``` **Next.js 15:** Wrap each read in `unstable_cache` and pass the tags: `lib/content.ts`: ```ts import { unstable_cache } from "next/cache"; import { tagsFor } from "@usegraft/sdk-next"; import { graft } from "@/lib/graft"; // unstable_cache stores results as JSON, so `updatedAt` comes back as a // string. Turn it back into a Date, so every value matches its type. function revive(doc: T): T { return { ...doc, updatedAt: new Date(doc.updatedAt) }; } export async function getPage(slug: string) { const page = await unstable_cache( () => graft.getContent("pages", slug), ["graft", "pages", slug], { tags: tagsFor("main", "pages", slug) }, )(); return page && revive(page); } const cachedPages = unstable_cache(() => graft.listContent("pages"), ["graft", "pages"], { tags: tagsFor("main", "pages"), }); export async function listPages() { return (await cachedPages()).map(revive); } ``` `unstable_cache` stores results as JSON, which has no date type, so `revive` turns each `updatedAt` back into a `Date`. Schema fields are not affected: `field.datetime()` values are ISO strings already. Without this step, `revalidateContent` has nothing to refresh and does nothing. **Add a route that refreshes what changed** `revalidateContent` takes the list of documents a compile changed and refreshes the matching tags: each changed document, and the collection it is in. `app/api/revalidate/route.ts`: ```ts import { revalidateContent, type ChangeSet } from "@usegraft/sdk-next"; const isStrings = (value: unknown): value is string[] => Array.isArray(value) && value.every((item) => typeof item === "string"); function isChangeSet(value: unknown): value is ChangeSet { const v = value as Partial | null; return ( isStrings(v?.added) && isStrings(v?.changed) && isStrings(v?.removed) && typeof v?.unchanged === "number" ); } export async function POST(request: Request) { const secret = process.env.GRAFT_WEBHOOK_SECRET; if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } const body = await request.json().catch(() => null); const branch = body?.branch ?? "main"; if (typeof branch !== "string" || !isChangeSet(body?.changes)) { return Response.json({ error: "Send { branch, changes }." }, { status: 400 }); } const tags = revalidateContent(branch, body.changes); return Response.json({ revalidated: tags }); } ``` **Point Graft at the route** Add two variables to the `.env` Graft reads, or to the environment, wherever `graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate` runs. The secret must match the one the route checks: `.env`: ```sh GRAFT_REVALIDATE_URL=https://example.com/api/revalidate GRAFT_WEBHOOK_SECRET=a-long-random-string ``` The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the app's runtime environment, such as your host's environment variables. Otherwise the route answers 401 and nothing refreshes. Graft now calls the route after every write that changes content: an agent's `write_content` or `delete_content`, a local Studio save, create, delete, discard, compile or revert, and `graft merge --apply` or `graft migrate --apply`. The URL must use https, except on loopback (`localhost`, `127.0.0.1`, `[::1]`). If the route fails, the write still succeeds. The server logs a `REVALIDATE_FAILED` error, and an agent's result carries `refresh: { ok: false, fix }`, so the agent knows not to retry the write. To refresh those pages, POST the body the server logged with the error to the route. Recompiling does not help: the index already has the change, so a new compile reports those documents as unchanged. If your app mounts the MCP endpoint itself, skip the webhook. Agent writes run in your app's process, so refresh there: `app/api/mcp/route.ts`: ```ts import { createGraftMcpHandler } from "@usegraft/mcp"; import { revalidateContent } from "@usegraft/sdk-next"; import { collections, functions } from "@/graft.config"; import { actor } from "@/lib/actor"; import { db } from "@/lib/graft"; export const POST = createGraftMcpHandler({ contentDir: "./content", db, collections, functions, actor, // An agent's write lands in this app's process, so refresh the cache here. onContentChange: ({ branch, changes }) => { revalidateContent(branch, changes); }, }); ``` `graft compile` never calls the route: a deploy compiles before the new version is live. To refresh after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET` exported in its environment, which is how most CI systems expose secrets. The script stops if the compile fails, and `--fail` makes a refused request fail the script too: ```sh graft compile --json > changes.json || exit 1 curl --fail -X POST https://example.com/api/revalidate \ -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \ -H "content-type: application/json" \ --data-binary @changes.json ``` Either way, the route reads a body shaped like this: ```json { "branch": "main", "gitSha": "b8eb719a468be404bae9adca4fbea4d90c642411", "changes": { "added": ["pages/pricing"], "changed": ["pages/home"], "removed": [], "unchanged": 12 } } ``` To refresh inside the same request instead, for example from a Server Action that saves and then shows the result, use `updateContent(branch, changes)`. It needs Next.js 16. On 15 it throws `FRAMEWORK_VERSION_UNSUPPORTED`, whose fix points you back to `revalidateContent`. ## Run Graft on Next.js 14 Graft does not support Next.js 14, and you should not run it in production. This section is for a project that has to adopt Graft before it can upgrade Next.js. The adapter still runs on 14.2, but CI no longer tests it. The last release tested on 14.2.35 is 1.0.0-beta.8. > **Next.js 14 has security advisories that will never be fixed.** > 14.2.35 is the last Next.js 14 release, and it no longer gets security > fixes. 23 published Next.js advisories affect it. Two are critical: remote > code execution through the image optimization API, and remote code execution > on servers hosted on Windows. Every one is fixed only in 15 and 16. What changes from the steps above: - **Install.** The adapter's peer range is `next@^15.0.0 || ^16.0.0`. pnpm and bun install anyway and print a peer warning. npm refuses with `ERESOLVE` unless you pass `--legacy-peer-deps`: ```sh npm i @usegraft/sdk-next @usegraft/db --legacy-peer-deps ``` - **Config.** Next.js 14 cannot load a TypeScript config. Name the file `next.config.mjs`. `withGraft` writes the externals setting under the name 14 reads, `experimental.serverComponentsExternalPackages`: `next.config.mjs`: ```js import { withGraft } from "@usegraft/sdk-next/config"; export default withGraft({ // your existing Next.js options }); ``` - **The static index.** Set `"target": "ES2017"` in `tsconfig.json`, which the static index version of `lib/graft.ts` needs on 14. - **Cached reads.** Use the Next.js 15 tab of [Refresh pages when content changes](#refresh-pages-when-content-changes): `unstable_cache` and `revalidateContent` work the same on 14. `updateContent` throws `FRAMEWORK_VERSION_UNSUPPORTED`. - **The Pages Router** runs on React 18 there. Reads are not deduped, because React 18 has no `React.cache`, and each data function reads once per page anyway. `withGraft` prints this each time Next.js loads your config: ```text graft: Graft does not support Next.js 14. It no longer gets security fixes, and 14.2.35 has critical advisories that will not be fixed on 14. Upgrade to 15.5.24 or 16.3.3 or later. Until you can, see https://graft.page/docs/next#run-graft-on-nextjs-14. ``` ## Next steps - [Schema](/docs/schema) defines the collections and fields your pages read. - [Reading content](/docs/reading-content) covers lists, search, and pagination. - [Connecting agents](/docs/connecting-agents) points Cursor or Claude at your project. --- # Astro > Read Graft content in Astro page frontmatter, ship no client JavaScript, and serve functions from Astro routes. *Render Graft content in an Astro site that ships no client JavaScript.* This guide adds Graft to an Astro project. You read content in a page's frontmatter, so it is fetched at build or request time and the page ships no client JavaScript for it. You can also serve Graft's functions and MCP endpoint from Astro routes. **Before you start**, you need an Astro project and a `graft.config.ts` with compiled content. If you do not have those yet, run through [Getting started](/docs/getting-started) first. > **Prompt for a coding agent:** > Add Graft to this Astro site following https://graft.page/docs/astro.md. > Create the client in src/lib/graft.ts and render the pages collection at > src/pages/[slug].astro. ## Install **pnpm:** ```sh pnpm add @usegraft/sdk-astro @usegraft/db ``` **npm:** ```sh npm i @usegraft/sdk-astro @usegraft/db ``` **bun:** ```sh bun add @usegraft/sdk-astro @usegraft/db ``` Create the client once in a server-only module, then import it from frontmatter, endpoints, and middleware. Never from a client island. ## Create the Graft client `graft init` scaffolds the static index. This docs site is that path: `src/lib/graft.ts`: ```ts import { openStaticIndex } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-astro"; import { collections } from "../../graft.config"; export const graft = createGraft({ index: await openStaticIndex(".graft/index.db"), collections, }); ``` On Postgres: ```ts import { createDb } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-astro"; import { collections } from "../../graft.config"; export const db = createDb(process.env.DATABASE_URL!).db; export const graft = createGraft({ db, collections }); ``` ## Read a document `src/pages/[slug].astro`: ```astro --- import { graft } from "../lib/graft"; const page = await graft.getContent("pages", Astro.params.slug!); --- ``` Astro frontmatter always runs server-side. No other adapter can promise that. Unlike Next.js, Astro has no built-in way to share one read across a request, so reading the same document twice reads the index twice. That is rarely worth worrying about: prerendered pages read once at build time, and a server-rendered page makes only a few reads. > **Rendering the body.** > Bodies come back as authored source. Render them with Astro's own MDX pipeline. > There is no `MdxBody` here. That evaluator is Next-only. ## Serve functions and MCP from your app This step is optional, and it needs the Postgres engine: functions and the audit log they write live in the database. The routes below import the `db` exported by the Postgres version of `src/lib/graft.ts` above. The static setup exports no `db`, so these routes do not work on it. It also needs three more packages: ```sh npm i @usegraft/core @usegraft/auth @usegraft/mcp ``` `graftRoute` binds a Graft handler to an Astro endpoint. It takes the handler, not a config object: Astro hands over an `APIContext`, and the handlers want its `request`. `src/pages/api/fn/[name].ts`: ```ts import { createFunctionsHandler } from "@usegraft/core"; import { graftRoute } from "@usegraft/sdk-astro"; import { functions } from "../../../graft.config"; import { db } from "../../lib/graft"; const handler = createFunctionsHandler({ db, functions }); export const POST = graftRoute(handler); export const GET = graftRoute(handler); // 405s with Allow and a fix ``` `src/pages/api/mcp.ts`: ```ts import { createActorResolver } from "@usegraft/auth"; import { createGraftMcpHandler } from "@usegraft/mcp"; import { graftRoute } from "@usegraft/sdk-astro"; import { collections, functions } from "../../graft.config"; import { db } from "../lib/graft"; // A dev token never expires, so it is accepted only in development. An unset // NODE_ENV counts as production, so a deploy that forgets it stays closed. const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined; const actor = createActorResolver({ issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "") .split(/[,\s]+/) .filter(Boolean) .map((issuer) => ({ issuer })), devTokens: devToken ? { [devToken]: { kind: "human", id: "owner", scopes: ["content:write"], }, } : undefined, }); export const POST = graftRoute( createGraftMcpHandler({ contentDir: "./content", db, collections, functions, actor, }), ); ``` This endpoint serves content writes and asset uploads. JWT verification runs only when `issuers` is passed; the library does not read `GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth). Unauthenticated callers get 401. `allowAnonymous: true` is only for a loopback local server. Never anything reachable from a network. A public docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write tools and no anonymous opt-in. The parameter is typed structurally as `{ request: Request }`, so this package needs no `astro` dependency. ## Refresh pages when content changes > Needs the Postgres storage engine. On the static engine, content changes ship with a new deploy, so there is nothing to refresh. On Postgres, content can change while your app is running, from a local Studio or from an agent. A server that writes to GitHub ([Host Studio for your team](/docs/studio-hosting)) is different: saves are drafts. Publishing either commits to your production branch or opens a pull request, and your host rebuilds when the change reaches that branch. A local Studio refreshes the app after a save that changes content, once the webhook below (or an `onContentChange` hook) is set up, and its Publish only commits in your checkout, which you push yourself. Astro has no tag-based data cache, so let your CDN cache pages and purge only the ones whose content changed. First, send each page with a header that lists the content it read. `tagsFor` builds that list: ```astro --- import { tagsFor } from "@usegraft/sdk-astro"; import { graft } from "../lib/graft"; const slug = Astro.params.slug!; const page = await graft.getContent("pages", slug); Astro.response.headers.set("Cache-Tag", tagsFor("main", "pages", slug).join(",")); --- ``` Then, when content changes, purge those tags at your CDN. `tagsForChanges(branch, changes)` turns a change list into the tags to purge: each changed document, and the collection it is in. Graft does not purge your CDN itself. Add a route that reads `branch` and `changes` from a POST body, checks a bearer secret, and purges the tags `tagsForChanges` returns. Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever `graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate` runs. The secret must match the one the route checks: `.env`: ```sh GRAFT_REVALIDATE_URL=https://example.com/api/revalidate GRAFT_WEBHOOK_SECRET=a-long-random-string ``` The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the app's runtime environment, such as your host's environment variables. Otherwise the route answers 401 and nothing refreshes. Graft now calls the route after every write that changes content: an agent's `write_content` or `delete_content`, a local Studio save, create, delete, discard, compile or revert, and `graft merge --apply` or `graft migrate --apply`. If the route fails, the write still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to resend. Recompiling does not resend it: a new compile reports those documents as unchanged. If your app mounts the MCP endpoint itself, pass `onContentChange` to `createGraftMcpHandler` and purge there instead. `graft compile` never calls the route: a deploy compiles before the new version is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET` exported in its environment, which is how most CI systems expose secrets. The script stops if the compile fails, and `--fail` makes a refused request fail the script too: ```sh graft compile --json > changes.json || exit 1 curl --fail -X POST https://example.com/api/revalidate \ -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \ -H "content-type: application/json" \ --data-binary @changes.json ``` `Cache-Tag` is the header Cloudflare reads, with tags separated by commas. Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header your CDN expects. ## Next steps - [Reading content](/docs/reading-content) covers list and search reads. - [SDK reference](/docs/sdk-reference) compares route mounts across frameworks. - [Connecting agents](/docs/connecting-agents) points an agent at your project. --- # SvelteKit > Read Graft content in SvelteKit server load functions and serve functions from +server.ts routes. *Read Graft content in SvelteKit server load functions.* This guide adds Graft to a SvelteKit project. You read content in server `load` functions, render it in your pages, and can serve Graft's functions and MCP endpoint from `+server.ts` routes. **Before you start**, you need a SvelteKit project and a `graft.config.ts` with compiled content. If you do not have those yet, run through [Getting started](/docs/getting-started) first. > **Prompt for a coding agent:** > Add Graft to this SvelteKit app following > https://graft.page/docs/sveltekit.md. Keep the client in > src/lib/server/graft.ts and read pages in +page.server.ts. ## Install **pnpm:** ```sh pnpm add @usegraft/sdk-sveltekit @usegraft/db ``` **npm:** ```sh npm i @usegraft/sdk-sveltekit @usegraft/db ``` **bun:** ```sh bun add @usegraft/sdk-sveltekit @usegraft/db ``` Create the client in `$lib/server/`, so SvelteKit's server-only enforcement guards it. The client holds a database connection and must never reach the browser. ## Create the Graft client `src/lib/server/graft.ts`: ```ts import { DATABASE_URL } from "$env/static/private"; import { createDb } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-sveltekit"; import { collections } from "../../../graft.config"; export const db = createDb(DATABASE_URL).db; export const graft = createGraft({ db, collections }); ``` Static index instead of Postgres: ```ts import { openStaticIndex } from "@usegraft/db"; export const graft = createGraft({ index: await openStaticIndex(".graft/index.db"), collections, }); ``` > **Keep the client on the server.** > Read from `+page.server.ts` or `+layout.server.ts`. A **universal** `load` in > `+page.ts` or `+layout.ts` also runs in the browser on client-side navigation, > so a database connection does not belong there. > > `node:sqlite` backs the static index and must stay external to the client > bundle. Keeping the client in `$lib/server/` is what guarantees that. ## Read a document `src/routes/[slug]/+page.server.ts`: ```ts import { error } from "@sveltejs/kit"; import { graft } from "$lib/server/graft"; import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async ({ params }) => { const page = await graft.getContent("pages", params.slug); if (!page) error(404); return { page }; }; ``` ## Serve functions and MCP from your app This step is optional: it needs the Postgres engine and three more packages: ```sh npm i @usegraft/core @usegraft/auth @usegraft/mcp ``` `src/routes/api/fn/[name]/+server.ts`: ```ts import { createFunctionsHandler } from "@usegraft/core"; import { graftRoute } from "@usegraft/sdk-sveltekit"; import { functions } from "../../../../graft.config"; import { db } from "$lib/server/graft"; const handler = createFunctionsHandler({ db, functions }); export const POST = graftRoute(handler); export const GET = graftRoute(handler); // 405s with Allow and a fix ``` `src/routes/api/mcp/+server.ts`: ```ts import { createActorResolver } from "@usegraft/auth"; import { createGraftMcpHandler } from "@usegraft/mcp"; import { graftRoute } from "@usegraft/sdk-sveltekit"; import { collections, functions } from "../../../../graft.config"; import { db } from "$lib/server/graft"; // A dev token never expires, so it is accepted only in development. An unset // NODE_ENV counts as production, so a deploy that forgets it stays closed. const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined; const actor = createActorResolver({ issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "") .split(/[,\s]+/) .filter(Boolean) .map((issuer) => ({ issuer })), devTokens: devToken ? { [devToken]: { kind: "human", id: "owner", scopes: ["content:write"], }, } : undefined, }); export const POST = graftRoute( createGraftMcpHandler({ contentDir: "./content", db, collections, functions, actor, }), ); ``` This endpoint serves content writes and asset uploads. JWT verification runs only when `issuers` is passed; the library does not read `GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth). Unauthenticated callers get 401. `allowAnonymous: true` is only for a loopback local server. Never anything reachable from a network. A public docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write tools and no anonymous opt-in. The parameter is typed structurally as `{ request: Request }`, so this package needs no `@sveltejs/kit` dependency. ## Refresh pages when content changes > Needs the Postgres storage engine. On the static engine, content changes ship with a new deploy, so there is nothing to refresh. On Postgres, content can change while your app is running, from a local Studio or from an agent. A server that writes to GitHub ([Host Studio for your team](/docs/studio-hosting)) is different: saves are drafts. Publishing either commits to your production branch or opens a pull request, and your host rebuilds when the change reaches that branch. A local Studio refreshes the app after a save that changes content, once the webhook below (or an `onContentChange` hook) is set up, and its Publish only commits in your checkout, which you push yourself. SvelteKit has no tag-based data cache, so let your CDN cache pages and purge only the ones whose content changed. First, send each page with a header that lists the content it read. `tagsFor` builds that list: ```ts import { tagsFor } from "@usegraft/sdk-sveltekit"; export const load: PageServerLoad = async ({ params, setHeaders }) => { const page = await graft.getContent("pages", params.slug); setHeaders({ "Cache-Tag": tagsFor("main", "pages", params.slug).join(",") }); return { page }; }; ``` Then, when content changes, purge those tags at your CDN. `tagsForChanges(branch, changes)` turns a change list into the tags to purge: each changed document, and the collection it is in. Graft does not purge your CDN itself. Add a route that reads `branch` and `changes` from a POST body, checks a bearer secret, and purges the tags `tagsForChanges` returns. Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever `graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate` runs. The secret must match the one the route checks: `.env`: ```sh GRAFT_REVALIDATE_URL=https://example.com/api/revalidate GRAFT_WEBHOOK_SECRET=a-long-random-string ``` The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the app's runtime environment, such as your host's environment variables. Otherwise the route answers 401 and nothing refreshes. Graft now calls the route after every write that changes content: an agent's `write_content` or `delete_content`, a local Studio save, create, delete, discard, compile or revert, and `graft merge --apply` or `graft migrate --apply`. If the route fails, the write still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to resend. Recompiling does not resend it: a new compile reports those documents as unchanged. If your app mounts the MCP endpoint itself, pass `onContentChange` to `createGraftMcpHandler` and purge there instead. `graft compile` never calls the route: a deploy compiles before the new version is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET` exported in its environment, which is how most CI systems expose secrets. The script stops if the compile fails, and `--fail` makes a refused request fail the script too: ```sh graft compile --json > changes.json || exit 1 curl --fail -X POST https://example.com/api/revalidate \ -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \ -H "content-type: application/json" \ --data-binary @changes.json ``` `Cache-Tag` is the header Cloudflare reads, with tags separated by commas. Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header your CDN expects. ## Next steps - [Reading content](/docs/reading-content) covers list and search reads. - [SDK reference](/docs/sdk-reference) compares route mounts across frameworks. - [Connecting agents](/docs/connecting-agents) points an agent at your project. --- # TanStack Start > Read Graft content in TanStack Start through server functions, never in isomorphic loaders. *Read Graft content in TanStack Start through server functions.* This guide adds Graft to a TanStack Start project. You read content through server functions, so the database or index file never reaches the browser, and you can serve Graft's functions and MCP endpoint from server routes. **Before you start**, you need a TanStack Start project and a `graft.config.ts` with compiled content. If you do not have those yet, run through [Getting started](/docs/getting-started) first. > **Prompt for a coding agent:** > Add Graft to this TanStack Start app following > https://graft.page/docs/tanstack-start.md. Create the client in > src/lib/graft.server.ts and read content through a createServerFn, never in > a route loader. ## Install **pnpm:** ```sh pnpm add @usegraft/sdk-tanstack-start @usegraft/db ``` **npm:** ```sh npm i @usegraft/sdk-tanstack-start @usegraft/db ``` **bun:** ```sh bun add @usegraft/sdk-tanstack-start @usegraft/db ``` Create the client once in a server-only module, then reach it from server functions and server routes. ## Create the Graft client `src/lib/graft.server.ts`: ```ts import { createDb } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-tanstack-start"; import { collections } from "../../graft.config"; export const db = createDb(process.env.DATABASE_URL!).db; export const graft = createGraft({ db, collections }); ``` Static index instead of Postgres: ```ts import { openStaticIndex } from "@usegraft/db"; export const graft = createGraft({ index: await openStaticIndex(".graft/index.db"), collections, }); ``` > **Do not read in a route loader.** > A route `loader` is the wrong place for this client. TanStack Start loaders > are **isomorphic**: they run on the server for the first paint and in the > browser on client-side navigation. A client that holds a database connection > belongs in neither the client bundle nor the browser, which is what > `createServerFn` is for. ## Read a document `src/routes/docs.$slug.tsx`: ```ts import { createServerFn } from "@tanstack/react-start"; import { graft } from "../lib/graft.server"; const getDoc = createServerFn({ method: "GET" }) .validator((slug: string) => slug) .handler(({ data: slug }) => graft.getContent("docs", slug)); ``` Each call reads the index. If one request reads the same document in several places, cache the result yourself. ## Serve functions and MCP from your app This step is optional: it needs the Postgres engine and three more packages: ```sh npm i @usegraft/core @usegraft/auth @usegraft/mcp ``` Copy these mounts as written. The server-route factory name has moved across TanStack Start releases; the handler signature has not. `src/routes/api/fn/$name.ts`: ```ts import { createServerFileRoute } from "@tanstack/react-start/server"; import { createFunctionsHandler } from "@usegraft/core"; import { graftRoute } from "@usegraft/sdk-tanstack-start"; import { functions } from "../../../graft.config"; import { db } from "../../lib/graft.server"; const handler = createFunctionsHandler({ db, functions }); export const ServerRoute = createServerFileRoute("/api/fn/$name").methods({ POST: graftRoute(handler), GET: graftRoute(handler), // 405s with Allow and a fix }); ``` `src/routes/api/mcp.ts`: ```ts import { createActorResolver } from "@usegraft/auth"; import { createServerFileRoute } from "@tanstack/react-start/server"; import { createGraftMcpHandler } from "@usegraft/mcp"; import { graftRoute } from "@usegraft/sdk-tanstack-start"; import { collections, functions } from "../../graft.config"; import { db } from "../lib/graft.server"; // A dev token never expires, so it is accepted only in development. An unset // NODE_ENV counts as production, so a deploy that forgets it stays closed. const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined; const actor = createActorResolver({ issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "") .split(/[,\s]+/) .filter(Boolean) .map((issuer) => ({ issuer })), devTokens: devToken ? { [devToken]: { kind: "human", id: "owner", scopes: ["content:write"], }, } : undefined, }); export const ServerRoute = createServerFileRoute("/api/mcp").methods({ POST: graftRoute( createGraftMcpHandler({ contentDir: "./content", db, collections, functions, actor, }), ), }); ``` This endpoint serves content writes and asset uploads. JWT verification runs only when `issuers` is passed; the library does not read `GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth). Unauthenticated callers get 401. `allowAnonymous: true` is only for a loopback local server. Never anything reachable from a network. A public docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write tools and no anonymous opt-in. The parameter is typed structurally as `{ request: Request }`, so this package needs no `@tanstack/react-start` dependency. ## Refresh pages when content changes > Needs the Postgres storage engine. On the static engine, content changes ship with a new deploy, so there is nothing to refresh. On Postgres, content can change while your app is running, from a local Studio or from an agent. A server that writes to GitHub ([Host Studio for your team](/docs/studio-hosting)) is different: saves are drafts. Publishing either commits to your production branch or opens a pull request, and your host rebuilds when the change reaches that branch. A local Studio refreshes the app after a save that changes content, once the webhook below (or an `onContentChange` hook) is set up, and its Publish only commits in your checkout, which you push yourself. TanStack Start has no tag-based data cache, so let your CDN cache pages and purge only the ones whose content changed. First, send each page with a header that lists the content it read. `tagsFor` builds that list: ```ts import { createServerFn } from "@tanstack/react-start"; import { setResponseHeader } from "@tanstack/react-start/server"; import { tagsFor } from "@usegraft/sdk-tanstack-start"; import { graft } from "../lib/graft.server"; const getDoc = createServerFn({ method: "GET" }) .validator((slug: string) => slug) .handler(({ data: slug }) => { setResponseHeader("Cache-Tag", tagsFor("main", "docs", slug).join(",")); return graft.getContent("docs", slug); }); ``` The header goes on whichever response ran the function. On a full page load that is the page's HTML. On client-side navigation, the browser calls the server function directly, so it is that `GET` response. Let your CDN cache both: they carry the same tags, so one purge refreshes both. Then, when content changes, purge those tags at your CDN. `tagsForChanges(branch, changes)` turns a change list into the tags to purge: each changed document, and the collection it is in. Graft does not purge your CDN itself. Add a route that reads `branch` and `changes` from a POST body, checks a bearer secret, and purges the tags `tagsForChanges` returns. Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever `graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate` runs. The secret must match the one the route checks: `.env`: ```sh GRAFT_REVALIDATE_URL=https://example.com/api/revalidate GRAFT_WEBHOOK_SECRET=a-long-random-string ``` The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the app's runtime environment, such as your host's environment variables. Otherwise the route answers 401 and nothing refreshes. Graft now calls the route after every write that changes content: an agent's `write_content` or `delete_content`, a local Studio save, create, delete, discard, compile or revert, and `graft merge --apply` or `graft migrate --apply`. If the route fails, the write still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to resend. Recompiling does not resend it: a new compile reports those documents as unchanged. If your app mounts the MCP endpoint itself, pass `onContentChange` to `createGraftMcpHandler` and purge there instead. `graft compile` never calls the route: a deploy compiles before the new version is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET` exported in its environment, which is how most CI systems expose secrets. The script stops if the compile fails, and `--fail` makes a refused request fail the script too: ```sh graft compile --json > changes.json || exit 1 curl --fail -X POST https://example.com/api/revalidate \ -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \ -H "content-type: application/json" \ --data-binary @changes.json ``` `Cache-Tag` is the header Cloudflare reads, with tags separated by commas. Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header your CDN expects. ## Next steps - [Reading content](/docs/reading-content) covers list and search reads. - [SDK reference](/docs/sdk-reference) compares route mounts across frameworks. - [Connecting agents](/docs/connecting-agents) points an agent at your project. --- # React Router > Read Graft content in React Router framework mode loaders and serve functions from resource routes. *Read Graft content in React Router framework mode.* This guide adds Graft to a React Router app in framework mode. You read content in route loaders on the server, and you can serve Graft's functions and MCP endpoint from resource routes. **Before you start**, you need a React Router app in framework mode and a `graft.config.ts` with compiled content. If you do not have those yet, run through [Getting started](/docs/getting-started) first. > **Prompt for a coding agent:** > Add Graft to this React Router app following > https://graft.page/docs/react-router.md. Create the client in > app/lib/graft.server.ts and read the pages collection in a route loader. ## Install **pnpm:** ```sh pnpm add @usegraft/sdk-react-router @usegraft/db ``` **npm:** ```sh npm i @usegraft/sdk-react-router @usegraft/db ``` **bun:** ```sh bun add @usegraft/sdk-react-router @usegraft/db ``` For framework mode, which is where React Router runs loaders and actions on a server. Create the client once in a `.server.ts` module. ## Create the Graft client `app/lib/graft.server.ts`: ```ts import { createDb } from "@usegraft/db"; import { createGraft } from "@usegraft/sdk-react-router"; import { collections } from "../../graft.config"; export const db = createDb(process.env.DATABASE_URL!).db; export const graft = createGraft({ db, collections }); ``` Static index instead of Postgres: ```ts import { openStaticIndex } from "@usegraft/db"; export const graft = createGraft({ index: await openStaticIndex(".graft/index.db"), collections, }); ``` ## Read a document `app/routes/docs.$slug.tsx`: ```ts import { graft } from "../lib/graft.server"; import type { Route } from "./+types/docs.$slug"; export async function loader({ params }: Route.LoaderArgs) { return { doc: await graft.getContent("docs", params.slug) }; } ``` `loader` and `action` run on the server and React Router strips them from the browser bundle. The `.server.ts` name is belt and braces on top of that: it turns a stray client import into a build error rather than a database URL in a bundle. To read content in the browser (a search box, an editor preview), use [the React adapter](/docs/react). ## Serve functions and MCP from your app This step is optional: it needs the Postgres engine and three more packages: ```sh npm i @usegraft/core @usegraft/auth @usegraft/mcp ``` `graftRoute` binds a Graft handler to a resource route. That is a route module with no default export, whose loader and action return raw Responses. `app/routes/api.fn.$name.ts`: ```ts import { createFunctionsHandler } from "@usegraft/core"; import { graftRoute } from "@usegraft/sdk-react-router"; import { functions } from "../../graft.config"; import { db } from "../lib/graft.server"; // export the same db you passed to createGraft const handler = createFunctionsHandler({ db, functions }); export const action = graftRoute(handler); // POST export const loader = graftRoute(handler); // GET, which 405s with Allow and a fix ``` `app/routes/api.mcp.ts`: ```ts import { createActorResolver } from "@usegraft/auth"; import { createGraftMcpHandler } from "@usegraft/mcp"; import { graftRoute } from "@usegraft/sdk-react-router"; import { collections, functions } from "../../graft.config"; import { db } from "../lib/graft.server"; // A dev token never expires, so it is accepted only in development. An unset // NODE_ENV counts as production, so a deploy that forgets it stays closed. const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined; const actor = createActorResolver({ issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "") .split(/[,\s]+/) .filter(Boolean) .map((issuer) => ({ issuer })), devTokens: devToken ? { [devToken]: { kind: "human", id: "owner", scopes: ["content:write"], }, } : undefined, }); export const action = graftRoute( createGraftMcpHandler({ contentDir: "./content", db, collections, functions, actor, }), ); ``` This endpoint serves content writes and asset uploads. JWT verification runs only when `issuers` is passed; the library does not read `GRAFT_TRUSTED_ISSUERS` (`graft serve` does). See [Auth](/docs/auth). Unauthenticated callers get 401. `allowAnonymous: true` is only for a loopback local server. Never anything reachable from a network. A public docs surface is [`createDocsMcpHandler`](/docs/docs-mcp), which has no write tools and no anonymous opt-in. Register them the way you register any route: `app/routes.ts`: ```ts import { route, type RouteConfig } from "@react-router/dev/routes"; export default [ route("api/fn/:name", "routes/api.fn.$name.ts"), route("api/mcp", "routes/api.mcp.ts"), ] satisfies RouteConfig; ``` > **Why the route exports a loader.** > React Router handles GET requests with a route's `loader` export and all > other methods with its `action` export, so the same Graft handler is > exported twice. Keep the `loader` export even though functions are called > with POST. Without it, a GET to a function URL gets React Router's generic > "no loader" error. With it, the caller gets Graft's 405 response, which says > which method to use. ## Refresh pages when content changes > Needs the Postgres storage engine. On the static engine, content changes ship with a new deploy, so there is nothing to refresh. On Postgres, content can change while your app is running, from a local Studio or from an agent. A server that writes to GitHub ([Host Studio for your team](/docs/studio-hosting)) is different: saves are drafts. Publishing either commits to your production branch or opens a pull request, and your host rebuilds when the change reaches that branch. A local Studio refreshes the app after a save that changes content, once the webhook below (or an `onContentChange` hook) is set up, and its Publish only commits in your checkout, which you push yourself. React Router has no tag-based data cache, so let your CDN cache pages and purge only the ones whose content changed. First, send each page with a header that lists the content it read. `tagsFor` builds that list: ```ts import { tagsFor } from "@usegraft/sdk-react-router"; import type { Route } from "./+types/docs.$slug"; export function headers({ params }: Route.HeadersArgs) { return { "Cache-Tag": tagsFor("main", "docs", params.slug).join(",") }; } ``` Then, when content changes, purge those tags at your CDN. `tagsForChanges(branch, changes)` turns a change list into the tags to purge: each changed document, and the collection it is in. Graft does not purge your CDN itself. Add a route that reads `branch` and `changes` from a POST body, checks a bearer secret, and purges the tags `tagsForChanges` returns. Then point Graft at the route. Add two variables to the `.env` Graft reads, or to the environment, wherever `graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate` runs. The secret must match the one the route checks: `.env`: ```sh GRAFT_REVALIDATE_URL=https://example.com/api/revalidate GRAFT_WEBHOOK_SECRET=a-long-random-string ``` The app's route reads `GRAFT_WEBHOOK_SECRET` too, so set the same value in the app's runtime environment, such as your host's environment variables. Otherwise the route answers 401 and nothing refreshes. Graft now calls the route after every write that changes content: an agent's `write_content` or `delete_content`, a local Studio save, create, delete, discard, compile or revert, and `graft merge --apply` or `graft migrate --apply`. If the route fails, the write still succeeds, and the server logs a `REVALIDATE_FAILED` error with the body to resend. Recompiling does not resend it: a new compile reports those documents as unchanged. If your app mounts the MCP endpoint itself, pass `onContentChange` to `createGraftMcpHandler` and purge there instead. `graft compile` never calls the route: a deploy compiles before the new version is live. To purge after a deploy, send the compile's output yourself. A shell does not read `.env`, so the deploy script needs `GRAFT_WEBHOOK_SECRET` exported in its environment, which is how most CI systems expose secrets. The script stops if the compile fails, and `--fail` makes a refused request fail the script too: ```sh graft compile --json > changes.json || exit 1 curl --fail -X POST https://example.com/api/revalidate \ -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \ -H "content-type: application/json" \ --data-binary @changes.json ``` `Cache-Tag` is the header Cloudflare reads, with tags separated by commas. Fastly reads `Surrogate-Key`, with tags separated by spaces. Use the header your CDN expects. ## Next steps - [Reading content](/docs/reading-content) covers list and search reads. - [SDK reference](/docs/sdk-reference) compares route mounts across frameworks. - [Connecting agents](/docs/connecting-agents) points an agent at your project. --- # React > Read Graft content from browser React code with hooks that call the Content API. *Read Graft content from React code that runs in the browser.* `@usegraft/sdk-react` gives you React hooks that read content over HTTP. Use it when your React code runs in the browser and cannot open the index itself: - a single-page app built with Vite, or - an interactive component that loads content after the page is on screen, such as a live search box or a filter. The hooks call Graft's [Content API](/docs/content-api), so something has to serve it: `graft serve`, or the Content API handler mounted in your own backend. The browser never receives a database connection or the index file. **Before you start**, you need a `graft.config.ts` and a running Content API. By the end of this page you have a typed client, a component that shows one document, and a search box. > **Server-rendered app?** > If you use Next.js, Astro, SvelteKit, TanStack Start, or React Router with > loaders, read on the server with that framework's package instead. It reads > the index directly, with no extra HTTP request. > **Prompt for a coding agent:** > Add Graft content to this React app following > https://graft.page/docs/react.md. Use createGraftHooks with the Content API > endpoint at /api/content/v1. ## Install **npm:** ```sh npm i @usegraft/sdk-react ``` **pnpm:** ```sh pnpm add @usegraft/sdk-react ``` **bun:** ```sh bun add @usegraft/sdk-react ``` It needs React 18 or 19. ## Create the client and hooks Create the client once, pointed at your Content API, and build the hooks from it: `src/graft.ts`: ```ts import { createGraft, createGraftHooks } from "@usegraft/sdk-react"; import { collections } from "../graft.config"; export const graft = createGraft({ endpoint: "https://cms.example.com/api/content/v1", collections, }); export const { GraftProvider, useContent, useContentList, useContentSearch } = createGraftHooks(graft); ``` The hooks are typed from your own `graft.config.ts`, so `post.data.title` is checked at compile time. There is no code generation step and no schema to download. The hooks read from this client by default, so you do not need a provider. Wrap a part of your app in `` only when it should read from somewhere else, such as a preview deployment's endpoint or a fake client in tests. ## Show one document `useContent` reads a document by collection and slug. It returns `loading`, `error`, `data`, and a `refresh` function to try again: `src/BlogPost.tsx`: ```tsx import { useContent } from "./graft"; export function BlogPost({ slug }: { slug: string }) { const { data: post, loading, error, refresh } = useContent("posts", slug); if (loading) return

Loading…

; if (error) { return (

Could not load this post.

); } if (!post) return

Post not found.

; return (

{post.data.title}

{post.body}
); } ``` `data` is `null` when the collection has no document with that slug, so a missing post is not an error. `post.body` is the Markdown or MDX source as written. Render it with your own Markdown or MDX renderer. ## Add a search box `useContentSearch` runs a full-text search in one collection, best match first. An empty query returns no results and sends no request, so you can pass the input's value straight in: `src/SearchBox.tsx`: ```tsx import { useState } from "react"; import { useContentSearch } from "./graft"; export function SearchBox() { const [query, setQuery] = useState(""); const { data: hits, loading, error } = useContentSearch("docs", query, { limit: 10 }); return (
setQuery(e.target.value)} /> {loading &&

Searching…

} {error &&

Search failed.

} {query.trim() !== "" && hits?.length === 0 &&

No results.

}
); } ``` Each change to a non-blank `query` sends one request. For a busy endpoint, debounce the input before you pass it on. Each hit also has a `snippet`: the matching text with each match wrapped in `…`. The tags come from the index and the rest of the snippet is your content, so do not insert it with `dangerouslySetInnerHTML` unless you trust everyone who can write content. Split on the tags instead: ```tsx function Snippet({ text }: { text: string }) { return (

{text.split(/([\s\S]*?)<\/b>/g).map((part, i) => i % 2 === 1 ? {part} : part, )}

); } ``` ## Use TanStack Query or SWR instead The hooks keep the latest result in component state, and nothing more. If you want shared caching, retries, or background refresh, call the client from your data library instead. It has the same methods: `getContent`, `listContent`, and `searchContent`. ```tsx import { useQuery } from "@tanstack/react-query"; import { graft } from "./graft"; export function usePost(slug: string) { return useQuery({ queryKey: ["posts", slug], queryFn: () => graft.getContent("posts", slug), }); } ``` ## What the browser cannot do - **Reach the database.** The client only speaks HTTP. It has no option for a database connection, so a connection string cannot end up in your bundle. - **Choose a branch.** Each Content API endpoint serves one branch, set by the server. Passing `branch` to the client or to a read throws `CONFIG_INVALID`. To read a preview branch, point a second client at the preview deployment's endpoint. - **Render on the server.** During server rendering the hooks return their loading state. Content that must be in the first HTML belongs in your framework's server package. ## Next steps - [Content API](/docs/content-api) shows how to serve the endpoint, with `graft serve` or mounted in your own backend. - [Schema](/docs/schema) defines the collections your hooks are typed from. - [SDK reference](/docs/sdk-reference) lists every method on the client. --- # Schema > Describe the fields of each kind of content once, and get checked files, checked inputs, and typed reads from it. *Describe your content once, and get types everywhere from it.* The schema says what fields each kind of content has: a page has a title and an optional tagline, a product has a name and a price. You write it once, in `graft.config.ts`, and Graft uses it in three places: - `graft compile` checks every file against it, and stops with a clear error when one does not match. - [Typed functions](/docs/functions-and-access) check their inputs against it. - Every read in your app is typed from it, so your editor knows each field. It is built on [Zod](https://zod.dev), a TypeScript validation library, so there is no code generation step. This page declares a collection, shows the file and the typed read that go with it, and lists every field type. ## Declare a collection A **collection** is a group of documents with the same fields, stored in `content//`: `graft.config.ts`: ```ts import { defineCollection, field } from "@usegraft/core"; export const pages = defineCollection({ name: "pages", description: "Marketing and site pages.", fields: { title: field.string({ maxLength: 120, description: "Headline and ." }), tagline: field.string({ optional: true }), image: field.asset({ optional: true }), }, }); ``` The `description`s are for agents. An agent that calls `describe_schema` reads them to learn what each collection and field is for, so write them as you would explain the field to a new teammate. ## What a matching file looks like Each document is a file whose frontmatter matches the fields: `content/pages/about.mdx`: ```mdx --- title: About us tagline: We build with code and AI agents. --- The body is Markdown or MDX. ``` And your app reads it with full types: ```ts const about = await graft.getContent("pages", "about"); about?.data.title; // string about?.data.tagline; // string | undefined about?.data.color; // TypeScript error: pages has no field "color" ``` ## What happens when a file does not match If a file is missing a required field, or a value has the wrong type or is too long, `graft compile` stops with `SCHEMA_VALIDATION_FAILED`. The message names the file and the field, so you know exactly what to fix. Nothing is written to the index until every file passes. ## Choose a field type | Builder | Holds | Use it for | | --- | --- | --- | | `field.string()` | A short string | Titles, names, slugs | | `field.text()` | A long string | Summaries and longer text | | `field.number()` | A number | Prices, quantities, ratings | | `field.boolean()` | `true` or `false` | Flags such as `featured` | | `field.datetime()` | An ISO date and time | Publish dates | | `field.asset()` | `{ key, alt? }` | Images and files. See [Assets](/docs/assets) | | `field.select({ options })` | One value from a fixed list | A status, a section, a size | | `field.reference({ to })` | The slug of a document in another collection | A product's category, a post's author | | `field.object({ fields })` | A nested group of fields | An author with a name and a URL | | `field.array({ of })` | A list of another field | Tags, FAQ entries | | `field.json()` | Any JSON value | A last resort. Prefer an object or array, which are checked and typed | Every builder accepts `optional: true` and a `description`. ## Limit what a field accepts | Option | Applies to | Effect | | --- | --- | --- | | `maxLength` | `string`, `text` | The longest allowed value | | `pattern` | `string`, `text` | A regular expression the value must match | | `min`, `max` | `number` | The allowed range | | `int: true` | `number` | Whole numbers only | | `maxItems` | `array` | The most entries allowed | | `format: "money"` | `number` | An amount in the currency's minor unit (cents for USD, whole yen for JPY). Implies `int`. Studio shows and edits it as money | | `currency` | `number` with `format: "money"` | The ISO currency code Studio shows. Defaults to `USD` | | `label` | every field | What Studio calls the field, such as `"Price"`. The key stays the same | Set `maxLength` on any string or text field a public form writes to, and `maxItems` on any array, so one request cannot store megabytes. Set `max` on numbers that get multiplied, such as a quantity times a price. `field.json()` has no size limit, so keep it out of public forms. ## Choices and references `field.select` limits a value to a list. Each option is a string, or a value with the label people see: ```ts status: field.select({ options: ["draft", { value: "active", label: "On sale" }, "archived"], }), ``` `field.reference` stores the slug of a document in another collection. The file stays readable (`category: summer-sale`), Studio offers a picker over the target collection, and `describe_schema` tells an agent where to look the value up: ```ts category: field.reference({ to: "categories" }), ``` The slug has to name a document that exists. `graft compile` stops with `CONTENT_REFERENCE_NOT_FOUND` when a reference points at nothing, and names the file, the field and the missing slug. In a database-sourced project, publishing refuses the same thing, and also refuses to publish the deletion of a document that live documents still point at. Publish the documents that point at it in the same step, changed to point elsewhere. A local Studio and `write_content` check the same thing before they write, so a refused save, bulk edit, delete or discard leaves every file as it was. A save is checked against the documents in the index, or a file in the content folder the index hasn't caught up with, such as one a `git pull` brought in. A reference that was already broken somewhere else doesn't stop an unrelated save; `graft compile` still reports it. A reference into a collection kept in the database (`db-authoritative`) is not checked, because its targets are records, not documents. Both appear in `describe_schema` with their options and target, alongside each field's `label` and limits, so an agent and Studio see the same rules the validator applies. ## Nest fields Combine `field.array` and `field.object` for repeated groups, such as FAQ entries: ```ts faqs: field.array({ of: field.object({ fields: { question: field.string(), answer: field.text(), }, }), optional: true, }), ``` ## Export your collections Your config must export `collections`, and `functions` if you define any. The CLI loads the file directly, so there is no build step, and your app imports the same file for its typed reads. If you added ready-made features with `graft add`, combine them with your own collections using `mergePrimitives`: `graft.config.ts`: ```ts import { mergePrimitives } from "@usegraft/core"; import * as primitives from "./graft"; export const { collections, functions } = mergePrimitives([ { collections: { pages }, functions: {} }, primitives, ]); ``` Two collections or functions with the same name stop the config from loading with `CONFIG_INVALID`. Nothing is overridden silently. [Owned primitives](/docs/owned-primitives) covers `graft add`. ## See it live: this page's own schema This table is generated from the schema of the `docs` collection that renders the page you are reading. The same description answers an agent's `describe_schema` call and fills Studio's schema view, so it always matches what the compiler enforces. (Field table for the `docs` collection: see the HTML page.) ## Next steps - [Reading content](/docs/reading-content) covers the three reads. - [Assets](/docs/assets) covers image and file fields. - [Operational data](/docs/operational-data) covers collections your users write to. --- <!-- source: https://graft.page/docs/reading-content --> <!-- section: Content --> # Reading content > Get one document, list a collection, or search it, with the same three typed calls in every framework. *Get a document, list a collection, or search it. The same three calls work in every framework.* Once your content is compiled, your app reads it through a Graft client. This page covers the three reads you will use everywhere, the options they take, and how to read from a branch or from another server. Your framework guide shows where to create the client. Every example below assumes one called `graft`. ## Read one document `getContent` takes a collection and a slug, and returns the document or `null` if it does not exist: ```ts const home = await graft.getContent("pages", "home"); if (home) { home.data.title; // string, typed from your schema home.body; // the Markdown or MDX body, as written } ``` ## List a collection `listContent` returns every document in a collection. Pass `limit` and `offset` to page through a long one: ```ts const posts = await graft.listContent("posts", { limit: 20, offset: 0 }); ``` ## Search a collection `searchContent` runs a full-text search and returns the best matches first, each with a highlighted snippet of the matching text: ```ts const hits = await graft.searchContent("docs", "preview branch"); ``` [Search](/docs/search) explains the query syntax and how results are ranked. Every read checks each document against your schema on the way out. Deleted documents are skipped. A document that fails the check is not: the read throws `SCHEMA_VALIDATION_FAILED`, naming the document and the field, and a list or search that includes it fails as a whole. You never receive a document whose fields do not match its type. `graft compile` runs the same check, so this only happens when the index was built from files that no longer match the schema. ## Why the names differ outside a framework The framework packages call these reads `getContent`, `listContent`, and `searchContent`. Underneath, they all use `@usegraft/sdk-core`, whose methods are named `getDocument`, `listDocuments`, and `searchDocuments`. They take the same arguments and return the same types. Use the `sdk-core` names only when you call it directly, in a script or a tool with no framework. ## Read from a preview branch Pass `branch` to any read to get content from a [preview branch](/docs/branching-previews) instead of `main`: ```ts const page = await graft.getContent("pages", "pricing", { branch: "preview-pricing" }); ``` Branches need the Postgres engine. On the static engine the option is accepted and ignored, because each Git checkout compiles its own index. ## Read from another server Sometimes the app that renders pages cannot open the index itself, for example when it runs on a different host from your content. In that case, point the client at a [Content API](/docs/content-api) served by `graft serve`. Your reads stay exactly the same: ```ts import { createContentApiReader } from "@usegraft/content-api"; import { createGraft } from "@usegraft/sdk-astro"; import { collections } from "../../graft.config"; export const graft = createGraft({ index: createContentApiReader({ endpoint: "https://cms.example.com/api/content/v1", headers: { authorization: `Bearer ${process.env.GRAFT_CONTENT_TOKEN}` }, }), collections, }); ``` Each Content API endpoint serves one branch, so a production app cannot be switched onto preview content by a request parameter. ## Your framework - [Next.js](/docs/next): Server Components, MDX rendering, and cache refresh. - [Astro](/docs/astro): Frontmatter reads with no client JavaScript. - [SvelteKit](/docs/sveltekit): Server load functions and server routes. - [TanStack Start](/docs/tanstack-start): Server functions, never isomorphic loaders. - [React Router](/docs/react-router): Framework-mode loaders and resource routes. - [React in the browser](/docs/react): Hooks that read over HTTP. --- <!-- source: https://graft.page/docs/search --> <!-- section: Content --> # Search > Add full-text search to your site, with ranked results and highlighted snippets, and no search service to run. *Add full-text search to your site with no search service to run.* > Works on both storage engines, static and Postgres. Every compile also builds a full-text search index of your content. You do not run a search service, sync anything, or write indexing code. Call `searchContent` and you get the best matches first, each with a snippet showing where the words matched. ## Search a collection ```ts const hits = await graft.searchContent("docs", "preview branch"); for (const hit of hits) { hit.slug; // which document hit.data.title; // typed, like any read hit.snippet; // the matching passage, with matched words in <b> } ``` Agents search the same way with the `search_content` MCP tool. Each of their results also includes `sourcePath`, the file to edit. On a [preview branch](/docs/branching-previews), search covers the branch's changes plus everything it inherits from `main`, the same as any other read. ## What readers can type The query syntax is the one people already use in web search engines: | Type | Finds | | --- | --- | | `preview branch` | Documents with both words | | `"preview branch"` | That exact phrase | | `preview or draft` | Either word | | `branch -neon` | "branch", but not "neon" | An empty query fails with `INPUT_VALIDATION_FAILED`. On Postgres, a query made only of common words such as "the" returns no results rather than an error, because Postgres drops those words. The static engine keeps them, so the same query matches the documents that contain them. ## Show the snippets safely Snippets wrap the matched words in `<b>` tags, on both engines. To show the highlighting, render the snippet as HTML. Snippets come from your own content, but if writers you do not fully trust can edit it, strip every tag except `<b>` first. To show plain text instead, remove the tags. ## How results are ranked Matches in the slug count most, then matches in frontmatter text fields, then matches in the body, on both [storage engines](/docs/tiers). The query syntax is the same too. The scoring formula is not, so the same query finds the same kind of matches on both but can list them in a different order: | | Static engine | Postgres engine | | --- | --- | --- | | Search technology | SQLite FTS5 | Postgres full-text search (`tsvector`, GIN index) | | Ranking | `bm25`, weighted by field | `ts_rank`, weighted by field | | Extra setup | None | None beyond the database | ## Search your users' data To search [operational data](/docs/operational-data), such as form submissions, use `searchRecords` inside a typed function. It takes the same query syntax. Like every read of user data, it only runs inside a function, so access rules apply. It needs the Postgres engine. ## Language Search uses English word rules on both engines: it matches "branches" when someone types "branch". Other languages are not configurable yet. ## Next steps - [Reading content](/docs/reading-content) covers the other two reads. - [Operational data](/docs/operational-data) covers `searchRecords`. --- <!-- source: https://graft.page/docs/assets --> <!-- section: Content --> # Assets > Store images and other files in S3-compatible storage, reference them from content, and turn them into URLs in your app. *Keep images and files in object storage, and point to them from your content.* Images and other files do not belong in Git or in your content files. Graft stores them in any S3-compatible bucket, such as Cloudflare R2, AWS S3, Tigris, or MinIO. Your content stores only a pointer to the file: its **key** (its path in the bucket) and optional alt text. Your app turns the key into a URL when it renders the page. This page sets up storage, uploads a file, references it from a page, and shows it in your app. ## Set up storage Add your bucket's details to `.env`: `.env`: ```sh S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com S3_BUCKET=my-site-assets S3_ACCESS_KEY=… S3_SECRET_KEY=… S3_PUBLIC_URL=https://assets.example.com # optional, see below ``` `S3_REGION` defaults to `auto`, which suits R2. On AWS S3, set it to your bucket's region, such as `us-east-1`: AWS rejects requests signed for `auto`. [Configuration](/docs/configuration#assets-s3-compatible) lists every variable. ## Add an asset field `graft.config.ts`: ```ts fields: { title: field.string(), image: field.asset({ optional: true }), // → { key: string, alt?: string } }, ``` ## Upload a file From your terminal: ```sh graft asset put ./hero.svg pages/home/hero.svg ``` The second argument is the key. Keys are lowercase paths, and `..` is not allowed. The command prints the frontmatter to paste into your page. The file's content type is worked out from its name. Agents upload with the `put_asset` MCP tool. An agent on your machine passes a file `path`. A remote agent sends the file as `base64` with a `key`. Both refuse a key that already holds a file, with the error `ASSET_EXISTS`. The bucket keeps no history, so a replaced file is gone. Pick a new key for a new version of a file. To replace the file on purpose, pass `--overwrite` to `graft asset put`, or `overwrite: true` to `put_asset`. ## Reference it from content `content/pages/home.mdx`: ```mdx --- title: Home image: key: pages/home/hero.svg alt: Branch grafted onto a trunk --- ``` ## Show it in your app Turn the key into a URL with `@usegraft/assets`: `lib/assets.ts`: ```ts import { createStorage } from "@usegraft/assets"; const storage = createStorage(); // reads the S3_* variables export async function assetUrl(key: string): Promise<string> { return storage.url(key); } ``` ```tsx const home = await graft.getContent("pages", "home"); const src = home?.data.image ? await assetUrl(home.data.image.key) : undefined; ``` If you set `S3_PUBLIC_URL`, `url()` returns a plain public URL, which browsers and CDNs can cache. Without it, `url()` returns a signed link that works for a short time. Use a public URL for anything on public pages. Compiling also records which asset keys each document uses, so search and agents can see which pages use a file. ## Next steps - [Schema](/docs/schema) covers the other field types. - [Configuration](/docs/configuration) lists every storage variable. - [MCP tools](/docs/mcp-tools#put_asset) covers `put_asset` in full. --- <!-- source: https://graft.page/docs/owned-primitives --> <!-- section: Content --> # Owned primitives > Add ready-made features such as comments, SEO fields, or a product catalog as source code you own and can change. *Add ready-made features, such as comments, as code you own and can edit.* You want comments on your blog. You could write the collection, the functions to post and moderate, and the access rules yourself, or you could start from one that already works. Graft ships ready-made features like this as source code you copy into your project, the way shadcn/ui ships components. We call them **primitives**. There is no plugin system and no package to keep updated: once added, the code is yours to read and change. ## See what is available ```sh graft add ``` | Primitive | What you get | Needs | | --- | --- | --- | | `comments` | A comments collection, plus functions to post, list, moderate, and delete | Postgres | | `commerce` | Products as content, orders as runtime data, plus functions to place, list, update, and cancel orders | Postgres | | `scoped-access` | Named permission scopes and helpers for access rules | Either engine | | `seo` | SEO fields to add to any collection | Either engine | | `faq` | Structured FAQ fields and a block to render them | Either engine | | `callout` | A callout content block and its MDX component | Either engine | Agents can browse the same list with the `list_registry` and `describe_item` MCP tools. ## Add one Preview what it will create, then add it: ```sh graft add comments --dry-run # show the files, change nothing graft add comments ``` This copies the primitive's TypeScript into your `graft/` folder, along with anything it depends on, such as `scoped-access`. It also updates `graft/index.ts`, a file that re-exports everything in `graft/`. ## Connect it to your config Your config combines your own collections with everything in `graft/`, using `mergePrimitives`. Projects created with `graft init` already do this: `graft.config.ts`: ```ts import { mergePrimitives } from "@usegraft/core"; import * as primitives from "./graft"; export const { collections, functions } = mergePrimitives([ { collections: { pages }, functions: {} }, primitives, ]); ``` Run `graft compile`, and the new collection and functions are live. If a primitive's name clashes with one of yours, the config stops loading with `CONFIG_INVALID` instead of silently replacing either. ## Change it The files in `graft/` are yours. Rename fields, change the moderation rules, or delete functions you do not need. Graft does not overwrite them: running `graft add` again refuses to replace a file you changed, unless you pass `--overwrite`. If you need to undo your changes, Git has the original. ## Where primitives come from Primitives ship inside the `@usegraft/registry` package, versioned with the rest of Graft and tested with it. Each one is a small manifest: a content block, a set of fields, access rules, or a bundle of those. Third-party registries are not supported yet. The format is designed so they can be added later. ## Next steps - [Schema](/docs/schema) covers combining collections. - [Operational data](/docs/operational-data) explains the runtime data that `comments` and `commerce` use. - [Functions & access](/docs/functions-and-access) covers the functions they add. --- <!-- source: https://graft.page/docs/studio --> <!-- section: Content --> # Studio > An editor for people who would rather not edit files: find, create, edit and publish content in a browser, with every change kept in Git or the database. *Give writers and merchandisers an editor, without giving up Git.* > Works on both storage engines, static and Postgres. Not everyone on your team wants to edit Markdown files and run Git commands. Studio is an editor for them. They find an entry, change it in a form built from your schema, and publish. In a project that keeps its content in Git, every change ends up as files there, exactly as if a developer had edited them. In one that keeps it in the database, every change is a revision in Postgres. Nothing an editor can do in Studio is unavailable to an agent or a developer. Studio runs on both [storage engines](/docs/tiers). Editing, saving and committing content need no database. The views that keep their data in Postgres (approvals, the compile history, branches, accounts, agents, the media library and site settings) need one, and say so when it is missing. Your content stays where your project keeps it either way. ## Open Studio ```sh graft studio ``` This starts Studio on your machine, at port 4983, and opens your browser. Only your machine can reach it, and saves go to the files in your checkout. To give a team a Studio URL, with sign-in and saves that land on GitHub, see [Host Studio for your team](/docs/studio-hosting). ### Without a database `graft studio` starts without `DATABASE_URL`. You can find, edit, create and delete entries, and commit them, exactly as with one. - **In a static project** (`export const index = "static"`), each save also updates the static index, `.graft/index.db`, so your local site shows it at once, and Developer, Health compares your files with it. A static project never uses a database, even when a `DATABASE_URL` is set. - **In a Postgres project with no `DATABASE_URL`**, saves go to your files and the index waits for your next `graft compile` with a database. Approvals, the compile history, branches, people, agents, the media library and site settings keep their data in Postgres. Each of those views says it needs a database and how to add one. The Studio API answers their routes with `NEEDS_DATABASE` (HTTP 503) and the same steps in its `fix`. ### Who signs in A Studio started with `graft studio` is bound to your computer: it listens on `127.0.0.1` only, and anyone who can reach that address is you. It asks nobody to sign in, with a database or without one. To reach it from another machine, start it with `--host` and set `GRAFT_DEV_TOKEN`; requests then need that token as a bearer header, which is checked without a database. Studio accounts are kept in Postgres, and so are the sessions of the people who sign in with them, so accounts need a database. A Studio for a team runs under `graft serve --studio`, which needs `DATABASE_URL` anyway. Without a database there is no sign-in to offer, and none is needed, because only this computer can open Studio. ## Find your way around Content comes first. | Where | What it is for | | --- | --- | | Home | What is waiting for you, what was published lately and by whom, and every collection with its count | | Inbox | Changes sent for review, and agents asking before they delete or overwrite anything | | A collection | A table of its entries: search, filter, sort, select many | | An entry | Its fields and its content, saved as you type, between its collection's list and a panel with its details and history | | Publish | Every unpublished change, what changed in it, and the button that puts it live | | Developer | Index health, schema, compile history, branches and settings | Press <kbd>⌘K</kbd> anywhere to find an entry by name, create one, or jump to a collection. Search runs on the server, so it is as quick on five thousand products as on fifty. In a list, <kbd>J</kbd> and <kbd>K</kbd> move down and up from anywhere on the page, and once you are in it so do the arrow keys, <kbd>Home</kbd> and <kbd>End</kbd>. <kbd>Enter</kbd> opens the entry. ## Find content A collection opens as a table. The columns are the first few short fields of its schema, so a product list shows price, status and category without any configuration. - **Search** matches names, URL names and text fields. - **Filter** by status (all, unpublished, published) and by any choice, yes/no or reference field. - **Sort** by name, last edited or any column. - **Select** several entries to publish, discard or delete them together. The table loads a page at a time as you scroll. Search, filters and sort are kept in the address, so a reload, the back button or a link you send shows the same rows. The count leaves out entries you deleted: they stay in the list, marked Deleted, until you publish the deletion. ### Change a field on many entries Select the entries, then choose **Set a field** in the bar at the bottom of the list. Pick the field and its value, with the same control the entry editor uses, and Studio saves each entry as a draft. Nothing changes on your site until you publish (or commit) them. To take an optional field off every selected entry instead, tick **Remove … from these entries instead**. An empty value without that box ticked is refused, so one press of Enter can't wipe a field. Each entry is checked like any save, so a value the schema refuses is refused here too. If some entries can't be saved, the dialog lists them with the reason, and they stay selected so you can fix them and try again. The others are saved. You can set a choice, a number or amount, a yes/no field, a reference or a line of text. Lists, groups and images are edited one entry at a time. ## Edit an entry An entry opens as a page: its title at the top, its fields below, and its content last. Fields are built from your schema. Each has its label, says if it is optional, and shows its help text. Money is typed as money, choices are buttons or a list, references open a search over the other collection, lists of images are a gallery, and repeated groups such as product variants fold into one line each. See [Schema](/docs/schema) for how to declare them. Problems are shown on the field, in words, before anything is saved: "Keep the summary to 240 characters", "Price can't be less than $0.00". Studio does not save an entry until it is valid, which is the same rule every agent write follows. The content is edited in a rich editor that saves back to Markdown. Studio only offers it when it can save the content without changing anything you did not touch. When it can't, the content opens as Markdown beside the same form. This matters for MDX: a change as small as escaping `{` can break a page that looks identical. Use **Edit source** in the entry's menu to edit the whole file. ### The list and the panel An open entry keeps its collection's list on the left, with the entry marked, so the next one is a click away. The arrow keys move through the list and Enter opens an entry. The panel on the right has three tabs: - **Details**: whether the entry is published, where it is saved, its URL name, file and version. - **Changes**: what your draft changes, field by field. - **History**: every published version, who made it and when. Open one to see how the entry differs from it now, and press **Restore this version** to go back to it. A restore is a draft like any other edit: nothing on the site changes until you publish it. History comes from wherever the content lives: the published revisions when content is in the database, the production branch's commits on GitHub, and your checkout's commits on your machine. Press <kbd>⌘\</kbd> (<kbd>Ctrl+\</kbd>) to hide the panel for a full-width page. On a narrower window it opens over the entry instead of beside it. ### Saving Studio saves automatically, and never rewrites an entry you only opened. Every save carries the version you started from. If the entry changed somewhere else in the meantime (another tab, a teammate, an agent), Studio does not overwrite it. It shows you, and you choose to load the newer version or keep yours. On your machine, a save also updates the index for that entry, so your local site shows it straight away. Studio checks and projects only the file it saved, which keeps a save fast on a large catalog. A file you change outside Studio reaches the index on your next `graft compile`, or at once while `graft dev` is running. A save rewrites only the fields you changed. The rest of the frontmatter keeps its bytes, comments included, and an edited one-line string stays on one line, in the quotes it had, so the diff shows your change and nothing else. ## Publish Saved changes are drafts until you publish them. The Publish button in the top bar shows how many are waiting, and opens a sheet that lists them as entries ("Wool Hat, Edited") with the fields that changed ("Price: $25.00 → $29.00"). Choose what to include and press the button. What it does depends on where Studio runs: | Studio | Publish button | What happens | | --- | --- | --- | | On your machine | Commit | A Git commit of the selected files. Nothing is pushed | | On your machine, contributor | Commit, turned off | Nothing. The drafts wait for an editor to commit them | | Hosted, editor | Publish | One commit on your production branch; the site redeploys. With `GRAFT_STUDIO_PUBLISH=pull-request`, a pull request instead | | Hosted, contributor | Submit for review | A pull request for someone to merge | | Content in the database, editor | Publish | The entries go live on the site straight away. Nothing to rebuild | | Content in the database, contributor | Submit for review | A request someone who can publish approves | When your project keeps its content in the database (`content = { source: "database" }`, see [Configuration](/docs/configuration#content)), there is one draft per entry, shared by everyone who edits it, and Studio works the same on your machine and hosted. A commit on your machine is authored by the person signed in, when Studio has sign-in, and committed by whoever runs Studio. Leave **Describe the change** empty and the message names the entries, such as `Update “Wool Hat”`, or counts them past three. **Discard** throws a draft away and leaves the published version as it is. Deleting an entry is a draft too: it stays live until you publish the deletion, and the toast that follows a delete has an Undo. Until then the entry stays in its collection marked Deleted; open it to read it or press Restore. ## Inbox The inbox holds what is waiting on a decision: - **Changes for review.** A contributor's Submit for review lands here. When content is in the database, someone who can publish presses **Publish** on the request. On GitHub, the request is a pull request, and **Open on GitHub** takes you to it. - **Agents asking first.** A destructive call from an agent waits until a person approves or denies it. Nothing runs until then. The card says what it would do, such as **Delete projects/welup-digital**, and **Approve** asks once more before the agent may run it. See [Audit & approvals](/docs/audit-and-approvals). The sidebar shows how many things are waiting. ## Media **Media** in the sidebar lists every file in the asset store that Studio uploaded or an entry uses. Search by name or alt text, and press **Upload** to add images or PDFs (10 MB each at most). Open a file to copy its key, write its alt text, and see which entries use it. A file can be deleted only when nothing uses it, by someone who can publish. An image field has **Choose…**, which opens the same library: pick a file or upload one. The field takes the file's alt text when it has none of its own, and you can still word it differently for that entry. Media needs an asset store: set `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` and `S3_BUCKET` (any S3-compatible store) where Studio runs, and `S3_PUBLIC_URL` for public links. Without them the library says so, and image fields still take a key you type. ## Give people access On your own machine Studio asks for no login. A hosted Studio has sign-in: Studio accounts with a password, GitHub, Google, your own single sign-on provider, and invite links. An admin adds people in **Settings, People**, or lets a whole email domain join on its own. There are four roles: viewer, contributor, editor and admin. See [Host Studio for your team](/docs/studio-hosting#2-let-people-sign-in). The roles name scopes, which is also what bearer tokens carry: | Scope | Allows | | --- | --- | | `studio:read` | Browsing content, the schema, history and pending approvals | | `studio:write` | Saving drafts, discarding, compiling. Publishing opens a pull request | | `studio:publish` | Committing on your own machine, and publishing straight to the production branch | | `approvals:decide` | Approving and denying | | `studio:people` | Inviting people and changing their roles (admins) | | `studio:settings` | Agent tokens, the site's address and deploy hooks (admins) | ## Agents **Developer, Agents** gives an agent its own token, for the MCP endpoint and the Studio API. Name the token after the agent and pick how far it may go: | Access | The agent can | | --- | --- | | Read | Read content, never change it | | Write drafts | Write drafts, which a person publishes | | Publish | Write and publish | The token is shown once, so copy it then. Studio keeps only its hash, so a lost token is revoked and replaced, never shown again. Revoking takes effect on the agent's next request. No token can approve: destructive calls still wait in the inbox for a person. Only admins see this page. Tokens work at `/api/mcp` wherever the site runs `graft serve`. `graft studio` on your own machine has no MCP endpoint, so tokens made there are for the hosted site on the same database. An agent on your machine needs no token: it starts `npx graft mcp` itself. ## Site and deploy hooks **Settings, Site** holds the site's name and address. Studio shows them, and agents can read them. **Settings, Deploy hooks** lists URLs called with a `POST` after every publish: a host's build hook, so a static site rebuilds, or the site's own revalidate route, so cached pages refresh. A publish is a commit Studio makes to your production branch on GitHub, or a publish of content kept in the database. A commit on your own machine reaches no site, so it calls no hook: your host builds when you push it. Add a secret if the URL checks one; it is sent as `Authorization: Bearer <secret>`. Each hook shows what it answered last, and **Send a test** calls it now. A hook that fails never undoes the publish. The body names what went live: ```json title="Deploy hook body" { "event": "publish", "branch": "main", "paths": ["posts/hello-world.mdx"], "at": "2026-10-12T09:00:00.000Z" } ``` Hooks need https, or http to the same machine. They are called when content is in the database, and when an agent's `publish_drafts` commits to your production branch on GitHub. A pull request calls none until it is merged, when your host builds from the commit. ## Developer views Under **Developer**: index health (each document's state against the compiled index), the schema, the compile history with restore, branches, agents, and settings. The index states are explained in [How Graft works](/docs/the-model). ## Do it without Studio | In Studio | From the CLI or Git | Over MCP | | --- | --- | --- | | Find content | `graft content list`, `graft content search` | `list_content`, `search_content` | | Save an entry | Edit the file, then `graft compile`; or `graft content write` | `write_content` | | Delete an entry | Delete the file, or `graft content delete` | `delete_content` | | Publish, locally | `git commit` | | | Publish, hosted | Merge the draft branch (all of its changes at once) | `publish_drafts` | | Discard a draft | `git restore` | `discard_drafts` | | See an entry's history | `git log -- <file>` | | | Restore an old version | `git checkout <commit> -- <file>` | `write_content` with the old bytes | | See past compiles | `graft compilations` | `list_compilations` | | Approve or deny | `graft approve` / `graft deny` | `decide_approval` | The UI itself uses the [Studio API](/docs/studio-api), which you can call directly. ## Next steps - [Host Studio for your team](/docs/studio-hosting) puts Studio online with sign-in. - [Schema](/docs/schema) declares the fields Studio builds its forms from. - [MDX safety](/docs/mdx-safety) explains what a save refuses. --- <!-- source: https://graft.page/docs/mdx-safety --> <!-- section: Content --> # MDX safety > MDX can run code. Graft refuses the parts that run by default, so anyone who can write content cannot run code on your server. *Let writers and agents edit content without letting them run code.* MDX is Markdown that can include components. It can also run code. A `{expression}` in an MDX file runs JavaScript when the page renders, and an `import` can load any module. That code runs on your server, with access to `process.env`, `fetch`, and everything else your app can reach. When you are the only author and every change goes through review in Git, that power is useful. It becomes a risk as soon as someone else can write content: a writer using [Studio](/docs/studio), or an agent writing over MCP. Without a check, "can edit a page" would mean "can run code on your server". So, by default, Graft checks MDX on every path it writes or compiles: `write_content`, Studio saves, and `graft compile`. It refuses anything that can run code. A body written straight into the database skips the write-time check. In Next.js, `MdxBody` checks again when it renders, so even that body is refused there. Plain Markdown and components with plain text attributes still work. Setting `mdxTrust = "full"` (or `trust="full"` on `MdxBody`) turns the check off for that path. This page lists what is refused, where the check runs, and how to allow full MDX for content you write yourself. ## What is protected by default `mdxTrust` defaults to `"restricted"`, so you get this without configuring anything. `graft.config.ts`: ```ts export const mdxTrust = "restricted"; // the default ``` Under `"restricted"`, a body may be prose, Markdown, GFM, and components with literal string attributes. Everything that evaluates is refused. ## What is refused | Construct | Example | | --- | --- | | A `{…}` expression | `{process.env.SECRET}` | | An `import` or `export` | `import x from "y"` | | An attribute whose value is an expression | `<Foo bar={value} />` | | A spread attribute | `<Foo {...props} />` | | An element that can load or run code | `<script>`, `<iframe>`, `<object>`, `<embed>`, `<frame>`, `<frameset>`, `<base>` | | An inline event handler | `<img onerror="…" />` | The last two matter more than they look. `<script>alert(1)</script>` contains no expression at all, and MDX renders an unknown lowercase tag straight through to HTML. An `onerror` with a plain string value still runs in a browser, because React writes an unrecognized attribute into the markup as-is. ## Frontmatter is read as YAML or JSON The block between the opening and closing `---` is data, and Graft reads it as YAML, or as JSON when it opens with `---json`. A block that names any other language, such as `---js`, is refused with `INPUT_VALIDATION_FAILED` before any of it is read. A JavaScript block would run as code on the machine reading the file: the Studio server for a contributor's save, the MCP server for an agent's write, or CI for a pull request. This holds whatever `mdxTrust` is set to. Graft writes YAML frontmatter itself, so the fix is to rewrite the block as YAML. ## Where Graft checks Three places, and they are checking different things. | Surface | Why it checks | | --- | --- | | `graft compile` | Content already in git, so a bad document fails the build rather than a request | | MCP `write_content` | Content arriving from someone who is not the operator, refused before it is stored | | Studio saves | Same | | `MdxBody` at render, and `prepareMdx` | Content can also arrive by a direct database write with the runtime credential, which no write-side guard sees | The compile check is what makes the failure a build-time one. Without it, `MdxBody` refusing at render would mean an unchecked tree fails per-request in production instead. > **Next.js only.** > The render-time row is `MdxBody`, which lives in `@usegraft/sdk-next` because > it evaluates MDX in a Server Component. **No other adapter has a render-time > check.** Astro, SvelteKit, TanStack Start and React Router hand you the > authored source and you render it with your own pipeline, which is where that > responsibility then sits. This documentation site is one of them. > > So on those stacks the write-side guards and `graft compile` are the whole > defence, and the case the render check exists for (content inserted straight > into the index with the runtime credential, which no write-side guard sees) > is one your own renderer has to answer. `assertSafeMdx` from > `@usegraft/mdx-safety` is exported for exactly that. ### Compile reports all of them Refusals are collected rather than thrown per file. Within one body, every executable construct is listed; across the tree, every offending document is. An author fixing one at a time learns the rule slowly and resents it. One offender still fails the whole compile, so nothing is projected until the tree is clean. This is a tree-wide gate, not a per-document one. ### The errors Both are `INPUT_VALIDATION_FAILED`. They differ in what they can name. Compile names the documents, up to ten of them, then counts the rest: ```text Executable MDX in 3 document(s): docs/pricing.mdx ({price}), pages/home.mdx (<script>), docs/faq.mdx ({items.map}). ``` A single write (a `write_content` call, or a Studio save) names the constructs instead, with kind, line, and a truncated snippet for up to five, then counts the rest: ```text Executable MDX is not accepted in docs/pricing. Found a `{…}` expression (line 12): {price}. ``` An unparseable document gets its own message, because the checker cannot vouch for source it could not read. ## Allowing full MDX for content you trust `graft.config.ts`: ```ts export const mdxTrust = "full"; ``` Set this only when every author of the repository has commit access, because then code review is the control and MDX is just part of the source. `MdxBody` has its own `trust` prop with the same default, and the two must agree: ```tsx <MdxBody source={doc.body} trust="full" components={mdxComponents} /> ``` Setting `mdxTrust = "full"` and leaving the render site on the default indexes bodies that then fail to render. Compile says so in the error when it refuses a document. Anything other than the two known values is refused rather than defaulted, with `CONFIG_INVALID`. A typo would otherwise silently re-enable the restriction somebody was deliberately turning off, or the reverse. ## Limitations: it is not an HTML sanitizer It refuses constructs that **execute**. It is not an HTML sanitizer. It does not filter `style` attributes, `srcdoc`, or the long tail of markup that can be abused without running script inline. If you render content from people you do not trust at all, render it with your own pipeline and `rehype-sanitize`. This check closes the MDX-specific hole a Markdown sanitizer would not even see. One filter does apply to restricted bodies: `MdxBody` and `prepareMdx` drop any `href`, `src` or other URL attribute whose scheme is not `http`, `https`, `mailto` or `tel`, on Markdown links and on components alike. A `[Read more](javascript:…)` link keeps its text and loses the URL. React 19 blocks `javascript:` URLs on its own, and React 18, which the Pages Router can run on, does not. ## Writing components that pass Components are fully supported as long as attributes are literal strings: ```mdx <Callout label="Note"> Git is authoritative for authored content. </Callout> ``` ### Tips for components in MDX - **Place each JSX tag on its own line.** Markdown parsers treat JSX as a distinct block only when no markdown syntax shares its opening tag line. - **Leave blank lines around nested prose.** Ensure empty lines exist before and after markdown lists or code blocks nested inside JSX components. - **Keep components outside headings.** Adding a component inside an `##` heading modifies its slug anchor id and can break table-of-contents links. --- <details> <summary><strong>Deep dive: Why Graft validates the AST instead of using a VM sandbox</strong></summary> ### Why not a sandbox? Removing the executable surface at input validation was chosen over containing it at runtime. `node:vm` is explicitly not a security boundary, and running a worker thread sandbox cannot return real React elements back across the thread boundary without breaking component reference identity under React Server Components (RSC). What can be guaranteed safe is the input AST itself. ### Refusal of unparseable source Source that the checker cannot parse is refused rather than passed through. Assuming that unparseable source cannot execute is unsafe because independent parsers can drift. The checker parses with `remark-gfm` to guarantee parity with the runtime compiler. ### Executable vs HTML sanitization Graft's MDX gate specifically prevents arbitrary server-side code execution. If your application accepts content from untrusted external users, you should also incorporate `rehype-sanitize` into your render pipeline to strip unwanted HTML attributes (e.g. `style` or `srcdoc`). </details> ## Next steps - [Configuration](/docs/configuration#mdxtrust) documents the setting in context. - [Studio](/docs/studio) is where an author is not the operator. - [MCP tools](/docs/mcp-tools#write_content) documents the write path this guards. - [Schema](/docs/schema) is the other half of what a document must satisfy. --- <!-- source: https://graft.page/docs/content-in-the-database --> <!-- section: Content --> # Content in the database > Keep your content in Postgres so editors publish live with no rebuild and no GitHub setup, while developers pull it into files and push changes back. *Publishing is live in seconds. Your files are a checkout you pull and push.* > Needs the Postgres storage engine. By default your content is the MDX files in git. Every change goes through a commit, and the site rebuilds to show it. That suits a team of developers. For a site that people edit in Studio, it puts GitHub and a redeploy in front of every change. Keep the content in your Postgres database instead, and it works the way a hosted CMS does. A save is a draft. Publishing puts it on the live site straight away. A hosted Studio needs only `DATABASE_URL`. A project uses one source or the other, never both. The database source still stores MDX, checked by the same schema, so you can move between the two without losing anything. ## Switch a project over **Set the source** `graft.config.ts`: ```ts export const content = { source: "database" }; ``` The Postgres engine is required, so `index` must not be `"static"`. **Load your files** `Terminal`: ```sh graft db migrate graft content import ``` Every file is checked first, so one bad file stops the import before anything is written. Running it again is safe: it only brings in documents with nothing published, and never overwrites one that has been. To publish changed files over what is live, use `graft push` below. **Edit in Studio** Open Studio with `graft studio`, or host it with `graft serve --studio`. Saves are drafts, shared by everyone who edits that entry. Publish, and the change is live. ## What happens when someone publishes Studio and agents check a draft exactly as `graft compile` checks a file: the schema, unique slugs, and safe MDX. A draft that would fail a compile never goes live. Then the published version and the index change together, and your app is told to refresh its cache. If two people, or a person and an agent, save the same entry from the same version, the second save is refused with `CONTENT_CONFLICT` instead of overwriting the first. Someone without publish rights submits for review instead, and someone who can publish approves it. ## Work with the files The files under your content directory are a checkout, like a clone. Pull what is published, edit, and push your changes back. `Terminal`: ```sh graft pull # write what is published into your files graft push # publish the files you changed graft pull posts/launch # just one document ``` Graft remembers the version each file was pulled at, in `.graft/sync.json`. That is how it tells your changes from everyone else's: | Your file | Published version | What happens | | --- | --- | --- | | Unchanged since your pull | Changed | `graft pull` updates the file | | Changed | Unchanged since your pull | `graft push` publishes it. `graft pull` keeps it | | Changed | Also changed | A conflict. `graft push` refuses, and `graft pull` keeps your file and lists it | To settle a conflict, bring your change into the published version and push. `graft pull --force` takes the published version instead, and `graft push --force` publishes yours over it. While `graft dev` runs, it tells you when someone publishes, so you know your files are behind: `Terminal`: ```text Ana published posts/spring-sale. Run graft pull to bring it into your files. ``` An agent working in the repository can ask the same question with the MCP tool `sync_status`, or skip the files and use `write_content`, which writes to the database directly. Pulled files don't have to go anywhere. Commit them with your code when a content change ships with a code change, or leave them out of git entirely. ## Keep a copy in git To have history in GitHub without anyone running `graft pull`, mirror what is published: `graft.config.ts`: ```ts export const content = { source: "database", mirror: { mode: "batch", everyMinutes: 60 }, }; ``` Set `GRAFT_GITHUB_REPO` and `GRAFT_GITHUB_TOKEN` (or the GitHub App variables from [Host Studio for your team](/docs/studio-hosting)) to say where. | Setting | What it does | | --- | --- | | `mode: "batch"` | `graft serve` commits everything published since the last run, every `everyMinutes` (default 60, at most 10080, a week). `graft mirror` does one run, for a scheduled job | | `mode: "each"` | One commit after every publish | | `via: "pull-request"` | A pull request instead of a commit, merged straight away when the branch allows it. When it needs checks or a review it stays open, and later runs add to that one pull request until it merges | A day of publishing becomes one commit or a few, not one per change. Git is a record here, never a second source: the content is already live, so nothing that happens in git changes the site. ## Keep CI cheap Content changes don't need your full test suite. Three commands help: - `graft check` runs every check a publish makes on the files, with no database: the schema, slugs, MDX safety, and references to documents that don't exist. It takes seconds. - `graft ci init` writes a workflow that runs `graft check` when content changes. - `graft ci changes` prints `content-only=true` when a change touched nothing but content, and hands it to later workflow steps, so a heavy job can skip itself and still report a result. A path filter can't do that: a required check it skips never reports, and the pull request waits forever. `.github/workflows/test.yml`: ```yaml steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: graft run: npx graft ci changes - if: steps.graft.outputs.content-only != 'true' run: npm test ``` `graft ci skip-build` exits 0 when a deploy can be skipped: only content changed since the last deploy, and the site already serves it from the database. Use it as your host's command for deciding whether to build. A mirrored commit then costs no build at all. It compares with the previous deploy when the host says which that was, or with `--base <commit>`; with neither, it builds, since one push can hold a code change under a content one. ## What works differently Some operations work on files, so in a database-sourced project they refuse with `CONTENT_SOURCE_MISMATCH`: reverting to a commit in Studio, `graft merge`, and content migrations. For a content migration, pull, change the files, check them with `graft check`, and push. ## Next steps - [Configuration](/docs/configuration#content): every `content` setting. - [Studio](/docs/studio): what editors see. - [CLI reference](/docs/cli-reference): `pull`, `push`, `mirror`, `check` and `ci`. --- <!-- source: https://graft.page/docs/operational-data --> <!-- section: Runtime data --> # Operational data > Store data your users create at runtime, such as form submissions, orders, and comments, and read it back safely. *Save what your visitors submit, and read it back with the same types as your content.* > Needs the Postgres storage engine. Your site has a contact form. When a visitor submits it, the message has to be saved somewhere. It cannot be a Markdown file in Git: nobody wrote it ahead of time, and there is no file to rebuild it from. Graft calls data like this, created by your users while the site is running, **operational data**. Operational data lives in Postgres. Your app never writes it directly. It goes through a **typed function** you define, which checks the input against a schema, records who sent it, and applies your access rules and rate limits. By the end of this page you will have a contact form that saves submissions, a query that lists them for your team, and a picture of what changes when you add moderation, edits, and deletes. ## How operational data differs from content | | Content | Operational data | | --- | --- | --- | | Example | A pricing page | A contact-form submission | | Stored as | A file in `content/` | A row in Postgres | | Written by | You and agents, by editing files | Your users, through typed functions | | Read by | Your pages, search, and agents | Typed functions only | | Declared as | A normal collection | A collection with `authority: "db-authoritative"` | Graft keeps the two apart. A Markdown file in an operational collection's folder fails the compile, and an agent cannot write operational data with `write_content`. Both stop with `AUTHORITY_MISMATCH`, which tells you which collection is involved. ## Declare a collection Declare it like any other collection, plus one flag: `graft.config.ts`: ```ts export const submissions = defineCollection({ name: "submissions", authority: "db-authoritative", description: "Contact-form submissions. Written by submitContact.", fields: { email: field.string({ maxLength: 320, pattern: /^[^@\s]+@[^@\s]+\.[^@\s]+$/, description: "Sender address.", }), message: field.text({ maxLength: 4000, optional: true }), }, }); ``` Give every field a maximum length. Anyone on the internet can submit a public form, and an unbounded field lets them fill your database, however strict your rate limit is. ## Save a submission from a function A typed function receives the form's input, already checked against the schema, and saves it with `insertRecord`: `graft.config.ts`: ```ts export const submitContact = defineFunction({ name: "submitContact", kind: "mutation", public: true, rateLimit: { limit: 5, windowSeconds: 60 }, description: "Stores a contact-form submission.", returns: "{ id: string; receivedAt: string }", input: submissions.fields, handler: async (ctx) => { const record = await insertRecord(ctx, submissions, ctx.input); return { id: record.id, receivedAt: record.createdAt.toISOString() }; }, }); ``` Three lines in it are worth a closer look: - **`input: submissions.fields`** reuses the collection's fields as the function's input, so the form and the stored row can never disagree. - **`public: true`** lets visitors who are not signed in call it. Functions that write data refuse anonymous callers unless you say otherwise. - **`rateLimit`** allows each caller five submissions a minute. See [Rate limits](/docs/rate-limits). Your form posts to `POST /api/fn/submitContact`. Every saved row also records who sent it and a correlation id, so you can trace it later in the [audit log](/docs/observability). ## List submissions for your team There is no read SDK for operational data, on purpose: every read goes through a function, so who may read is decided in the same place as who may write. The defaults differ, though: a query is open to anyone until you give it an `access` rule. Write a **query** function with one: `graft.config.ts`: ```ts export const recentSubmissions = defineFunction({ name: "recentSubmissions", kind: "query", access: requireScopes("submissions:read"), returns: "{ id: string; email: string }[]", input: {}, handler: async (ctx) => { const rows = await listRecords(ctx, submissions, { limit: 20 }); return rows.map((row) => ({ id: row.id, email: row.data.email })); }, }); ``` > **Queries are open by default.** > Unlike mutations, query functions accept anonymous callers unless you add an > `access` rule. Any query that returns personal data, like this one, needs one. > [Auth](/docs/auth) explains scopes. `listRecords` returns the newest rows first. `limit` defaults to 50 and is capped at 500, because it often comes straight from a caller's input. ## Filter in the query, not afterwards Suppose a page lists approved comments. Use `match` so the database does the filtering: ```ts const approved = await listRecords(ctx, comments, { limit: 20, match: { pageSlug: "pricing", approved: true }, }); ``` This matters for correctness, not just speed. The limit applies before any filtering you do in JavaScript. If you fetched the 20 newest comments and then dropped the unapproved ones, anyone could hide every approved comment by posting 20 new unapproved ones. ## Search submissions `searchRecords` runs a full-text search over every text value in a record, with the same syntax as [content search](/docs/search): words, `"quoted phrases"`, `or`, and `-exclusions`. ```ts const hits = await searchRecords(ctx, submissions, 'refund "order 41" -spam'); ``` Like every read, it only runs inside a function. Graft has no open search endpoint for operational data. ## Change or remove a record To approve a comment or mark an order shipped, use `updateRecord`. It merges your change into the record and checks the result against the full schema. A change that would break the schema fails with `SCHEMA_VALIDATION_FAILED` and writes nothing. ```ts const updated = await updateRecord(ctx, orders, id, { status: "shipped" }); ``` To remove a record, use `deleteRecord`: ```ts const removed = await deleteRecord(ctx, submissions, id); ``` > **Deletes cannot be undone.** > `deleteRecord` removes the row for good. Git cannot restore it, because Git > never had it. Mark any function that calls it `destructive: true`, so it waits > for a human [approval](/docs/audit-and-approvals) before it runs. ## What previews do with this data [Preview branches](/docs/branching-previews) copy your content, never your users' data. A preview shows the site's pages without exposing real submissions: - An **overlay** branch starts with no operational rows at all. - A **Neon fork** starts with submissions and approvals cleared. The audit log is kept, because it is history. Approvals are cleared for a security reason. If a fork kept them, an approval used on the fork would still look unused on the original, so one approved delete could run once per fork. ## Next steps - [Functions & access](/docs/functions-and-access) covers everything a function can declare. - [Rate limits](/docs/rate-limits) protects a public form. - [Audit & approvals](/docs/audit-and-approvals) puts a human check on deletes. - [Migrations](/docs/migrations) updates existing records when the schema changes. --- <!-- source: https://graft.page/docs/functions-and-access --> <!-- section: Runtime data --> # Functions & access > Write server code that reads or changes your users' data, call it over HTTP, and control who may call it. *Define a function once, call it from your app or an agent, and decide who may run it.* > Needs the Postgres storage engine. A **typed function** is a piece of server code you declare in your config, such as "save a contact-form submission" or "list this week's orders". It is the only way to read or write [operational data](/docs/operational-data), and it is how agents run your business logic too. You write the handler. Graft does the rest around it: it checks the input against a schema, decides whether the caller is allowed in, applies rate limits, records the call in the audit log, and returns errors with a `fix`. This page shows how to declare a function, call it, and control access. ## Declare a function `graft.config.ts`: ```ts export const submitContact = defineFunction({ name: "submitContact", kind: "mutation", // "mutation" changes data, "query" only reads it public: true, // allow visitors who are not signed in rateLimit: { limit: 5, windowSeconds: 60 }, input: submissions.fields, handler: async (ctx) => { const record = await insertRecord(ctx, submissions, ctx.input); return { id: record.id, receivedAt: record.createdAt.toISOString() }; }, }); ``` Then add it to the `functions` record the same file exports. Graft serves only the functions in that record. Exporting the function on its own is not enough: a call to it answers `404 FUNCTION_NOT_FOUND`. `graft.config.ts`: ```ts export const functions = { submitContact }; ``` It is now served at `POST /api/fn/submitContact`. ## Call it from your app Send the input as a JSON object: `app/contact/actions.ts`: ```ts const response = await fetch("/api/fn/submitContact", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, message }), }); const result = await response.json(); // success: { data: { id, receivedAt } } // failure: { error, message, fix } ``` Every response, success or failure, carries an `x-graft-correlation-id` header. Log it: it is how you find this exact call in the [audit log](/docs/observability) when a user reports a problem. Agents call the same function through the `run_function` MCP tool, with the same checks. To serve the endpoint, mount Graft in your framework (see your [framework guide](/docs/next#serve-functions-and-mcp-from-your-app)) or run `graft serve`. ## Who can call it | Setting | Effect | | --- | --- | | A `mutation` with no `access` rule | Signed-in callers only. Anonymous calls are refused. | | `public: true` and no `access` rule | Anyone, including anonymous visitors. Use it for public forms. When `access` is set, `public` is ignored and the rule decides. | | A `query` with no `access` rule | Anyone. Add a rule to any query that returns private data. | | `access: requireScopes("submissions:read")` | Only callers whose token carries that scope. | Graft works out who the caller is from their token. [Auth](/docs/auth) covers how tokens are checked and where scopes come from. ## What your handler receives The handler gets one argument, `ctx`: | Field | What it is | | --- | --- | | `ctx.input` | The request body, already checked against `input` and typed | | `ctx.actor` | Who is calling: a user, an agent, or anonymous | | `ctx.branch` | The content branch the call runs on, usually `main` | | `ctx.db` | The database handle, for the record helpers | | `ctx.request` | The original HTTP request | | `ctx.correlationId` | The id returned in `x-graft-correlation-id` | Read and write data with the record helpers (`insertRecord`, `listRecords`, `updateRecord`, `deleteRecord`, `searchRecords`). Pass `ctx` as the first argument. They check every record against its schema and stamp it with the caller and correlation id. [Operational data](/docs/operational-data) shows each one. ## Protect functions that destroy data Mark a function `destructive: true` when its effect cannot be undone, such as deleting records. Under the default approval policy, it then needs a human's approval before it runs: 1. The first call does not run. It files an approval request and answers `403` with the request's id. 2. A person approves it, with `graft approve <id>` or in Studio. 3. The caller repeats the call with the id, in the `x-graft-approval` header (HTTP) or the `approval` argument (MCP). Now it runs. An approval works once, and only for the exact input that was approved. The approval policy in `graft.config.ts` changes who waits. `"human"` gates every mutation, not only destructive ones. `"unattended"` applies to HTTP calls only: there it gates nothing, so destructive functions run straight away. It exists for scheduled jobs with no person behind them. Over MCP, destructive calls still wait for a person. Every call is audited either way. [Audit & approvals](/docs/audit-and-approvals) covers the details, including asking for approval from inside an agent's session. ## Where functions can run A function is a standard web handler: it takes a `Request` and returns a `Response`, and keeps no state between calls. The same function runs inside any of the framework adapters, in the Graft container, or on serverless platforms, without changes. ## Next steps - [Operational data](/docs/operational-data) shows the record helpers in a complete example. - [Auth](/docs/auth) sets up identity and scopes. - [Rate limits](/docs/rate-limits) protects public functions. --- <!-- source: https://graft.page/docs/auth --> <!-- section: Runtime data --> # Auth > Tell Graft who is calling your functions and MCP endpoint, using the login system your app already has. *Tell Graft who is calling, using the login your app already has.* > Needs the Postgres storage engine. When someone calls one of your [typed functions](/docs/functions-and-access), or an agent connects over MCP, Graft needs to know who they are. It uses that to decide what they may do, and to record who did what in the audit log. Graft does not create accounts or log anyone in. Your app, or an identity provider such as Auth0, Clerk, or WorkOS, already does that and gives the signed-in user a token. Graft checks that token on each request and turns it into the caller's identity, which it calls the **actor**. This page gets you a working identity locally in one step, then connects your real identity provider for production, then shows how to require permissions. ## How Graft decides who is calling Each request falls into one of four cases: | The request carries | Graft treats the caller as | | --- | --- | | A valid token from an identity provider you trust | The token's subject, with the scopes the token lists. It is recorded as an agent unless the issuer is set with `actorKind: "human"` (`GRAFT_TRUSTED_ACTOR_KIND=human` for `graft serve`) | | Your local development token (`GRAFT_DEV_TOKEN`) | You, with the scopes you set | | No token | Anonymous | | A token that is expired, forged, or from an issuer you do not trust | Refused with `TOKEN_INVALID` (401) | The last row matters. A bad token is never quietly treated as anonymous, so a broken login shows up as an error instead of as a signed-out user who can still reach public functions. ## Try it locally with a development token Set a token and the scopes it should carry in your `.env`: `.env`: ```sh GRAFT_DEV_TOKEN=dev-secret GRAFT_DEV_SCOPES=submissions:read,submissions:admin ``` Requests that send `Authorization: Bearer dev-secret` now act as you, with those scopes. Use this only on your machine, never in a deployment. ## Connect your identity provider In production, Graft checks tokens from identity providers you trust. They must be OpenID Connect providers, which almost all of them are. **If you run `graft serve`**, list each provider's issuer URL, and the audience your provider puts in tokens for this API: `.env`: ```sh GRAFT_TRUSTED_ISSUERS=https://your-tenant.auth0.com/ GRAFT_TRUSTED_AUDIENCE=https://api.example.com # Optional. Record callers as people in the audit log. The default is agent. GRAFT_TRUSTED_ACTOR_KIND=human ``` Graft looks up each provider's public keys and checks every token's signature, issuer, and expiry. Scopes are read from the token's `scope`, `scopes`, or `permissions` claim. Two settings apply to every issuer in the list: - **`GRAFT_TRUSTED_AUDIENCE`** is the token audience (`aud`) Graft accepts. List more than one with commas. A token minted for another of your apps carries a different audience and is refused. If you leave it unset, the audience is not checked and any token the provider signs is accepted. `graft serve` prints a warning at startup when that happens. - **`GRAFT_TRUSTED_ACTOR_KIND`** is what the audit log records callers as: `agent` (the default) or `human`. Any other value stops `graft serve` from starting, with `INPUT_VALIDATION_FAILED`. If your issuers need different audiences or actor kinds, run Graft inside your app and set them per issuer, as below. **If Graft runs inside your own app**, create the resolver in code and pass it to Graft's handlers. None of the `GRAFT_TRUSTED_*` variables are read automatically here, so pass the issuers and their settings yourself: `lib/graft-auth.ts`: ```ts import { createActorResolver } from "@usegraft/auth"; // A dev token never expires, so it is accepted only in development. An unset // NODE_ENV counts as production, so a deploy that forgets it stays closed. const devToken = process.env.NODE_ENV === "development" ? process.env.GRAFT_DEV_TOKEN : undefined; export const actor = createActorResolver({ issuers: (process.env.GRAFT_TRUSTED_ISSUERS ?? "") .split(/[,\s]+/) .filter(Boolean) .map((issuer) => ({ issuer, // Only tokens minted for this app. Without it, any token from the // provider is accepted. audience: "https://api.example.com", // These tokens are people signing in. The default, "agent", suits // tokens your agents use. actorKind: "human", })), devTokens: devToken ? { [devToken]: { kind: "human", id: "owner", scopes: ["content:write"], }, } : undefined, }); ``` Pass `actor` to `createFunctionsHandler` and `createGraftMcpHandler`. Without any `issuers`, every real token is refused with `TOKEN_INVALID`. If your app hosts its own [Better Auth](https://www.better-auth.com) instance, use `betterAuthIssuer({ url })` in place of a plain `{ issuer }`. The Next.js example app does this. It is one option, not a requirement: any OpenID Connect provider works the same way. The same provider can sign your team in to a hosted Studio, next to GitHub and Google, so people use one login for your app and for Studio. See [Host Studio for your team](/docs/studio-hosting#2-let-people-sign-in). ## Require a permission Add an `access` rule to a function so only callers with a given scope can run it: `graft.config.ts`: ```ts import { requireScopes } from "@usegraft/auth"; export const recentSubmissions = defineFunction({ name: "recentSubmissions", kind: "query", access: requireScopes("submissions:read"), // … }); ``` Anonymous callers and callers without the scope are refused. To let anyone call a function, such as a public contact form, set `public: true` instead. [Functions & access](/docs/functions-and-access) lists the defaults. ## Identity for agents How an agent is identified depends on how it connects: - **On your machine (`graft mcp`).** The server uses `GRAFT_DEV_TOKEN` as the agent's identity. The token stays in the server, so it never appears in the agent's conversation. - **Over HTTP (`POST /api/mcp`).** The agent sends its token in the `Authorization` header of every request. The endpoint keeps no session, so a token sent only when the agent first connects does not cover later calls. MCP clients send the header on every request when it is set in their config. A single call can pass a different token to act as another user, if that token is valid. [Connecting agents](/docs/connecting-agents) covers both setups. ## Next steps - [Functions & access](/docs/functions-and-access) shows the access defaults for each kind of function. - [Audit & approvals](/docs/audit-and-approvals) records who did what. - [Connecting agents](/docs/connecting-agents) gives an agent an identity. --- <!-- source: https://graft.page/docs/rate-limits --> <!-- section: Runtime data --> # Rate limits > Stop a script from flooding a public function by limiting how often each caller can run it, with no extra service to run. *Limit how often each caller can run a function.* > Needs the Postgres storage engine. Your contact form accepts anonymous visitors, so anything on the internet can call it, including a script that submits ten thousand times a minute. A rate limit caps how many times each caller can run a function in a given window, and refuses the rest. Graft counts calls from the [audit log](/docs/audit-and-approvals), which already has a row for every call. You do not run Redis or keep a separate counter. ## Add a limit to a function `graft.config.ts`: ```ts export const submitContact = defineFunction({ name: "submitContact", kind: "mutation", public: true, rateLimit: { limit: 5, windowSeconds: 60 }, // 5 calls per caller per minute input: submissions.fields, handler: async (ctx) => { /* … */ }, }); ``` The window rolls: it always covers the last `windowSeconds`, rather than resetting at the top of each minute. So a caller cannot fire 5 calls at 12:00:59 and 5 more at 12:01:00. To set a default for every function, pass `rateLimit` to the handler. A function's own `rateLimit` overrides it: ```ts createFunctionsHandler({ functions, db, rateLimit: { limit: 60, windowSeconds: 60 } }); ``` `graft serve` sets that default for you: 60 calls per 60 seconds. ## What a limited caller sees The call after the limit is refused with HTTP `429` and a `Retry-After` header saying how many seconds to wait: ```json { "error": "RATE_LIMITED", "message": "\"submitContact\" allows 5 calls per 60s per caller; this caller has made 6.", "fix": "Wait for the window to pass (Retry-After is set) before retrying. …", "details": { "function": "submitContact", "limit": 5, "windowSeconds": 60 } } ``` ## How callers are counted - **Signed-in callers** are counted by who they are. Two users never share a limit, so one cannot lock out the other. - **Anonymous callers** are counted by IP address, when Graft knows it. `graft serve` records each connection's address. Inside your own app, Graft only knows it once you set `trustedProxyHops` (below). Until then, every anonymous caller shares one limit, which is strict: one busy caller can use it up for everyone. Every attempt counts, including ones that are refused. A caller who sends twenty invalid requests has used twenty calls. Graft records each call before checking it, which is also what stops a burst of simultaneous requests from all slipping under the limit at once. ### If your app is behind a proxy or CDN Behind a proxy, every request seems to come from the proxy's IP address. The real client address is in the `x-forwarded-for` header, but a client can write that header too, so Graft ignores it unless you say how many proxies you run: ```ts createFunctionsHandler({ functions, db, trustedProxyHops: 1 }); ``` Set it to the number of proxies in front of your app. Behind one nginx or one CDN, that is `1`. Graft then reads that many entries from the right of the header, the end your proxies write to, and uses the address your outermost proxy saw. This holds only if each counted proxy **appends** the address it received the request from to `x-forwarded-for`. With a single proxy, replacing the header with that address works too. With several, each must append, or the entries Graft counts back through are lost. nginx with `$proxy_add_x_forwarded_for`, and the major CDNs, do this. A proxy that passes the header through unchanged leaves the client's own text at the end, and a client can then pick its own limit. Do not count a proxy you do not control, and do not set the number higher than the proxies you run. ## Choose a limit - **Every public function needs one.** A `public: true` function accepts anyone, so its limit is all that stands between it and a script. The docs site's own contact form uses 5 per 60 seconds. - **Limit the size of each input too.** A rate limit caps how often someone can call, not how much they send each time. Give every field a maximum length: ```ts email: field.string({ maxLength: 320, pattern: /^[^@\s]+@[^@\s]+\.[^@\s]+$/ }), message: field.text({ maxLength: 4000, optional: true }), ``` ## Agents are limited too Agents run functions through the `run_function` MCP tool, which uses the same handler. A function's limit applies the same way whether a browser or an agent calls it. ## The content API The [Content API](/docs/content-api) serves your content to frontends over HTTP without checking who is asking, and it runs searches. Without a limit, one anonymous caller could keep your index busy. Give it its own limit: ```ts createContentApiHandler({ collections, branch: "main", index, rateLimit: { limit: 60, windowSeconds: 60 }, }); ``` `graft serve` sets 60 per 60 seconds here too. If you mount the handler behind a proxy that already limits requests, you can leave `rateLimit` out. This limit works differently from the function limit, in two ways: - **It counts in memory, per server process.** Writing a database row for every read would cost more than the reads it protects. So three server instances allow three times the limit, and a restart resets the count. If you need one exact limit across instances, set it in your proxy or CDN. - **Its window resets on a fixed schedule**, rather than rolling. It identifies callers the same way, including the `trustedProxyHops` setting, and refuses with `429` before running the query. ## What is not rate limited - Reads through the SDKs in your own app. They read the index directly and never pass through a handler. - MCP tools other than `run_function`. ## Limits need the audit log Function limits are counted from audit rows, so they cannot work with the audit log turned off. If a handler sets both `audit: false` and a rate limit, Graft refuses to start it, rather than quietly not limiting anything: ```text Remove `audit: false` from createFunctionsHandler, or drop the rateLimit configuration. ``` ## Next steps - [Functions & access](/docs/functions-and-access) covers who may call a function. - [Audit & approvals](/docs/audit-and-approvals) explains the rows the limit counts. - [Error reference](/docs/errors) documents `RATE_LIMITED`. --- <!-- source: https://graft.page/docs/audit-and-approvals --> <!-- section: Runtime data --> # Audit & approvals > See who ran what, and make destructive actions wait for a person to approve them. *Know who did what, and make dangerous actions wait for a human.* > Needs the Postgres storage engine. Once an agent can run your functions, two questions matter. Afterwards: who did what, and when? Beforehand: can it delete something without asking? Graft answers both: - The **audit log** records every function call, whoever made it. - **Approvals** make destructive functions wait until a person says yes. This page covers both, and the one setting for jobs that run with nobody there to approve. ## The audit log Every call to a function adds a row to the `audit_log` table, recording: - who called it (the actor), and which function - the branch, the result, and how long it took - the correlation id returned in the `x-graft-correlation-id` header - the Git commit of the code that served it Inputs are **not** recorded. The log shows who ran what and how it went, not the data they sent, so it never becomes a second copy of your users' personal data. The same rows are what [rate limits](/docs/rate-limits) count. To find one call, take the correlation id from the response and look it up. [Observability](/docs/observability) shows how. ## How an approval works Mark a function `destructive: true` and it never runs on the first call: ```text agent: POST /api/fn/deleteSubmission {"id": "…"} graft: 403 … { approvalId: "ap_…", fix: "…" } the call is held, not run human: graft approvals list what is waiting human: graft approve ap_… say yes agent: repeat the call with x-graft-approval: ap_… graft: 200 { data: … } now it runs ``` Over MCP, the agent passes `approval: "ap_…"` to `run_function` or `delete_content` instead of the HTTP header. Three rules make an approval hard to misuse: | Rule | What it prevents | | --- | --- | | **It works once.** | Reusing one "yes" for many deletes | | **It is tied to the exact input.** | Approving the deletion of record A, then using it to delete record B | | **The requester cannot approve it.** | An agent approving its own request | On a [hardened](/docs/hardening) deployment, the database enforces these too. Your app's database login can file an approval request and use an approved one, but it cannot decide one: only the operator login on your machine or in CI can approve or deny. A leaked app password still cannot approve its own deletes. ### Asking in-band (opt-in) The flow above sends the person to a second terminal. When you run the MCP server on your own machine, you are already sitting at it, so the server can ask you directly instead. The agent's client shows a confirmation, and saying yes completes the call in one step. MCP calls this **elicitation**. If the client does not support it, or the request fails, the call falls back to the approval-id flow above. ```ts createGraftMcp({ // … approvalElicitation: { decider: { kind: "human", id: "you@example.com" } }, }); ``` It is meant for local stdio servers, where the person answering really is the person at the keyboard. ## Choose what needs approval `approvalPolicy` sets which functions wait for a person: | Value | What waits for approval | Where it applies | | --- | --- | --- | | `"none"` (default) | Functions marked `destructive: true` | Everywhere | | `"human"` | Every function that changes data | Everywhere | | `"unattended"` | Nothing | HTTP functions only, never MCP | Set it in `graft.config.ts`, so a change to it shows up in code review. ## Jobs with nobody to approve A nightly cleanup job or a CI migration may need to run a destructive function with no person around. Under the default policy it would wait forever. For those deployments, turn approvals off for HTTP calls: `graft.config.ts`: ```ts export const approvalPolicy = "unattended"; ``` If you embed the handler yourself, pass it there instead: `createFunctionsHandler({ functions, db, approvalPolicy: "unattended" })`. Know what you are giving up. Git can bring back a deleted document, but nothing brings back deleted [operational data](/docs/operational-data) or a deleted asset. `"unattended"` says those losses are acceptable for this deployment. The server logs a warning on every start while it is on. What you keep is the record. Every call still writes its audit row, and access rules and rate limits still apply. > **Agents always need approval.** > `"unattended"` never applies to MCP. On `graft serve` it lifts approvals on > `POST /api/fn` and leaves `POST /api/mcp` gated. The MCP endpoint exists for > agents, and agents are exactly who approvals are for, so no setting lets an > agent run a destructive function without a person saying yes. ## Next steps - [Hardening](/docs/hardening) makes the database enforce who can approve. - [Observability](/docs/observability) reads the audit log. - [Connecting agents](/docs/connecting-agents) covers approving from the same terminal as the agent. --- <!-- source: https://graft.page/docs/agent-surface --> <!-- section: Agents & MCP --> # Agents overview > What an AI agent can do in a Graft project, how it connects, and the limits that keep it safe. *Let an AI agent write and manage content, under the same rules you follow.* Graft is built so that an AI agent can do the content work a person would do in a CMS dashboard: draft a page, fix a typo across fifty documents, upload an image, or look up why a compile failed. Graft has no admin dashboard that the agent would have to click through. Instead, it offers the agent the same operations you use from the command line, as **tools**. Agents reach those tools through the **Model Context Protocol (MCP)**, the open standard that Claude Code, Cursor, and most other agents use to call tools. You run one command, register it with your agent, and the agent can start working. ## Connect an agent in one step For an agent running on your machine, such as Claude Code or Cursor, serve the project over MCP: ```sh graft mcp ``` Then register it with your agent, usually in an `.mcp.json` file. For an agent running elsewhere, Graft also serves MCP over HTTP at `POST /api/mcp`. [Connecting agents](/docs/connecting-agents) covers both, with the exact configuration for each client. ## What an agent can do | To… | The agent calls | | --- | --- | | Learn your collections and fields | `list_collections`, `describe_schema` | | Read and search content | `list_content`, `get_content`, `search_content` | | Write or delete a document | `write_content`, `delete_content` (each compiles in the same call) | | Upload an image or file | `put_asset` | | Call your typed functions | `list_functions`, `describe_function`, `run_function` | | Find ready-made features to add | `list_registry`, `describe_item` | | Understand an error | `explain_error` | [MCP tools](/docs/mcp-tools) lists every tool with its inputs and results. Beyond tools, an agent can attach documents directly as context, and pick from ready-made workflows such as "author a document" or "fix an error". See [Resources & prompts](/docs/mcp-resources-and-prompts). ## What keeps an agent in bounds An agent gets no special access. Everything it does goes through the same checks as a person using the CLI or your app: - **Every write is checked against your schema**, and a bad document is refused with a message saying what to fix. - **MDX that could run code is refused** by default. See [MDX safety](/docs/mdx-safety). - **Functions apply their own rules.** `run_function` goes through the same handler as `POST /api/fn/<name>`, so access rules, rate limits, and the audit log apply exactly as they do for your app's users. - **Deletes wait for a human.** If a local `graft mcp` server (stdio) has opted into asking in-band and the agent's client supports it, the client asks you to confirm and the delete completes in one call. Otherwise the first `delete_content` call files an approval request and stops. A person approves it with `graft approve`, and only then does the agent's retry succeed. See [Audit & approvals](/docs/audit-and-approvals). ## Errors the agent can act on Every Graft error includes a `fix`: the next step, written plainly. An agent reads it and recovers without searching the docs. `explain_error` returns the full entry from the [Error reference](/docs/errors) for any code. Graft's test suite checks this on every change. It simulates an agent that knows nothing about the project except what the tools tell it. The simulated agent discovers the schema, writes a page, hits a validation error, recovers using `explain_error`, and reads the page back. If a change to a tool or an error message breaks that path, the build fails. ## A second, public endpoint for your docs A project can also serve a read-only MCP endpoint at `POST /mcp`, with no authentication, so agents can read your published documentation. It is a separate server, not a setting on the private one, so turning it on cannot expose anything else. [Public docs MCP](/docs/docs-mcp) explains what it serves and how to set it up. ## Next steps - [Connecting agents](/docs/connecting-agents) gets your agent connected. - [MCP tools](/docs/mcp-tools) lists every tool an agent can call. - [Audit & approvals](/docs/audit-and-approvals) covers the human checks on destructive actions. --- <!-- source: https://graft.page/docs/connecting-agents --> <!-- section: Agents & MCP --> # Connecting agents > Connect Claude Code, Cursor, or any MCP client to your Graft project, on your machine or over HTTP, and control what it may do. *Point Claude Code, Cursor, or any MCP client at your project.* This page gets an agent connected to your Graft project and explains what it is allowed to do once connected. There are two ways to connect, and both reach the same tools: - **On your machine (stdio).** The agent starts `graft mcp` as a local process. This is the usual setup for Claude Code and Cursor. Start here. - **Over HTTP.** The agent connects to your deployed app at `POST /api/mcp`, for example a hosted agent working on your live site. ## Connect an agent on your machine **Register Graft with your agent** Most clients read an `mcpServers` map. Add Graft to it: `.mcp.json (Claude Code) or .cursor/mcp.json (Cursor)`: ```json { "mcpServers": { "graft": { "command": "npx", "args": ["graft", "mcp"] } } } ``` Put the file in your project's root folder. Graft looks for `graft.config.ts` and `.env` starting from the folder it runs in, so the client must start it in your project. The project needs the CLI installed (`npm i -D @usegraft/cli`; Getting started does this). Without it, `npx graft` fetches an unrelated npm package named `graft` and the agent fails to connect. **Check that it works** Ask the agent to call `list_collections`. It needs no arguments and no permissions. On the static engine it needs no database either, so if it fails, the problem is the connection. On Postgres, `graft mcp` needs `DATABASE_URL` before it starts, so a failure can also be a missing variable or a database that is down. From there, `describe_schema` tells the agent everything it needs to write content, and `explain_error` turns any failure into a next step. To work on a [preview branch](/docs/branching-previews), add `--branch` to the arguments: `["graft", "mcp", "--branch", "preview"]`. If you wrap `graft mcp` in a script, never print anything from the script. The client reads standard output as the protocol, and stray text breaks it. ### What a local agent may do The agent is a process you started on your own machine, so Graft trusts it to write content: it can call `write_content`, `delete_content`, and `put_asset` with no permission setup. Each still needs its backend: `delete_content` files an approval, which needs the Postgres engine, and `put_asset` needs asset storage (the `S3_*` variables in [Assets](/docs/assets)). It cannot approve its own destructive actions. A delete waits for you to run `graft approve` in another terminal, unless you start the server with `--elicit-approvals`: ```sh graft mcp --elicit-approvals ``` Then the agent's client asks you to confirm right there, and approving finishes the call. See [Audit & approvals](/docs/audit-and-approvals#asking-in-band-opt-in). To let the agent run functions that require scopes, add them to `GRAFT_DEV_SCOPES` in `.env`. If `GRAFT_DEV_TOKEN` is set, the server uses it as the agent's identity for those calls, and the token itself never appears in the agent's conversation. ## Connect an agent over HTTP A deployed app serves MCP at `POST /api/mcp`. `graft serve` mounts it for you: ```sh graft serve --port 3903 ``` To mount it inside your own app, use `createGraftMcpHandler`. Your [framework guide](/docs/next#serve-functions-and-mcp-from-your-app) shows the route. The endpoint is stateless: each request is handled on its own, so it runs on serverless platforms too. It only answers `POST`. A `GET` returns `405`, which MCP clients understand as "this server does not push messages". ### Give the agent an identity Over HTTP, the agent sends a token when it connects, just like a user of your app. Graft checks it the same way, then uses it for every function the agent runs. [Auth](/docs/auth) covers setting up the providers Graft trusts. An agent that holds a user's token can act as that user for a single call by passing it as the `authorization` argument to `run_function`. ### Agents that run shell commands An agent that works in a terminal can use the graft CLI instead of an MCP connection. `graft content`, `graft schema` and `graft fn` run the same tools, `--json` prints exactly what each tool returns, and `--remote` sends them to a hosted site with a token saved by `graft login`. See [Script the CLI](/docs/cli). ### Anonymous agents Whether an agent without a token is allowed depends on where the server listens: | The server listens on | Agents without a token are | | --- | --- | | Your machine only (loopback) | Allowed, so local development needs no setup | | Anything else | Refused, unless you set `GRAFT_MCP_ALLOW_ANONYMOUS=1` | Only set that variable if your own proxy checks identity in front of Graft. The server warns at startup while it is set. > **Missing auth fails at startup.** > `createGraftMcpHandler` refuses to start unless you give it an `actor` > resolver or set `allowAnonymous: true`. This endpoint can write content, > upload files, and decide approvals, so forgetting auth stops the server with a > fix line instead of leaving the endpoint open. ## Permissions an agent needs | To | The agent needs | | --- | --- | | Read content, the schema, functions, and the registry | Nothing beyond reaching the endpoint | | Write or delete content, or upload files | The `content:write` scope | | Approve or deny actions | The `approvals:decide` scope, on an authenticated connection | | Run a function | Whatever that function's `access` rule requires | Give each token the fewest scopes that work. Writing content is a separate scope from reading, so you can hand every user of your app a read token without letting them edit content. ## A separate, public endpoint for your docs A project can also publish its documentation to agents at `/mcp`, with no token. It is a different server with no access to content writes, functions, or approvals. See [Public docs MCP](/docs/docs-mcp). ## Next steps - [MCP tools](/docs/mcp-tools) lists everything an agent can call. - [Resources & prompts](/docs/mcp-resources-and-prompts) covers documents and workflows an agent can attach. - [Auth](/docs/auth) explains how tokens are checked. --- <!-- source: https://graft.page/docs/mcp-tools --> <!-- section: Agents & MCP --> # MCP tools > All eighteen tools, what each one needs, and the annotations and output schemas a client can rely on. *Every tool an agent can call, with its inputs and results.* Use this page to look up a tool. The project MCP server offers eighteen tools, grouped below by what they do. To connect an agent, see [Connecting agents](/docs/connecting-agents). Tools give an agent no extra powers. `run_function` goes through the same handler as `POST /api/fn/<name>`, so access rules, the audit log, rate limits, and approvals apply exactly as they do for your app's users. ## Discovery | Tool | Input | Returns | | --- | --- | --- | | `list_collections` | none | Every registered collection on this branch | | `describe_schema` | none | Collections with full field descriptors, plus the project's functions | | `list_functions` | none | Every typed function | | `describe_function` | `name` | One function's input fields, kind, and flags | `describe_schema` returns the same description that Studio's schema view and the field tables in these docs are built from, so all three always agree. ## Reading content | Tool | Input | Notes | | --- | --- | --- | | `list_content` | `collection` | Reads the authored MDX files, not the index | | `get_content` | `collection`, `slug` | Returns data, body, and `sourcePath` | | `search_content` | `query`, `collection?`, `limit?` | Reads the compiled index; default limit 20 | `list_content` and `get_content` read your files, which are the source of truth, so they show your latest edits even before you compile. `search_content` searches the index, so it reflects the last compile. Writes through `write_content` compile as they go, so they are searchable at once. The query syntax is the familiar web-search one: words, `"quoted phrases"`, `or`, and `-exclusions`. On a preview branch, results include the documents the branch inherits from its parent chain (the branch it was forked from, and that branch's parent, up to `main`), with the nearest branch's version taking priority. All three include a link to each document's [resource](/docs/mcp-resources-and-prompts), so a client can attach a document without another call. ## Writing content | Tool | Scope | Notes | | --- | --- | --- | | `write_content` | `content:write` | Validates, writes `<contentDir>/<collection>/<slug>.mdx`, compiles | | `delete_content` | `content:write` | Destructive and human-gated | | `put_asset` | `content:write` | Uploads a binary for `field.asset` | | `list_drafts` | none | Hosted on GitHub only. This connection's unpublished changes | | `publish_drafts` | `content:write` | Hosted on GitHub only. A pull request, or a commit with `content:publish` | | `discard_drafts` | `content:write` | Hosted on GitHub only. Throws unpublished changes away | ### write_content Takes `collection`, `slug`, `data`, and an optional `body`. It replaces the document rather than patching it. Three refusals worth knowing: - A db-authoritative collection answers `AUTHORITY_MISMATCH`. Those rows are written through typed functions. - A `slug` inside `data` that disagrees with the `slug` argument answers `INVALID_SLUG`. Omit it from `data`. - An executable MDX body is refused before it is stored. See [MDX safety](/docs/mdx-safety). Frontmatter keys you do not change come back byte-identical. Only a real data change re-serializes the file. ### delete_content The first call files an approval and fails with its id. A human decides, then the caller retries the same collection and slug with `approval: "<id>"`. Approvals are one-shot and bound to that exact input. A missing document fails before an approval is filed, so nobody reviews a request for a file that was never there. On a server that opted into elicited approvals, and a client that supports elicitation, the confirmation happens in-band and the call completes in one step. The gate is the same either way. ### Drafts on a hosted server When `graft serve` writes through GitHub (`GRAFT_GITHUB_REPO` is set, see [Studio hosting](/docs/studio-hosting)), a deployment's files are read-only and nothing is written to them. `write_content` and `delete_content` then commit to a draft branch of the connection's own, `graft-studio/drafts/<identity>`, and return `gitSha: null`, because nothing was compiled: a draft must not reach the live site's index. `list_drafts` shows those changes, and `publish_drafts` sends them toward production. It opens a pull request unless the credential carries `content:publish` and the server publishes directly (`GRAFT_STUDIO_PUBLISH=commit`, the default), in which case it commits to the production branch. Either way the site changes when the change reaches that branch and the host redeploys: at once for a commit, after the merge for a pull request. A commit also calls the site's [deploy hooks](/docs/studio#site-and-deploy-hooks). A document the production branch also changed since the draft began is refused with `CONTENT_CONFLICT`; send `resolve: { "<path>": "mine" }` or `"theirs"` to decide. This is the same draft model a Studio editor uses, so an agent and a person never take different routes to the live site. ### put_asset Pass exactly one of `path` or `base64`. `path` only exists on a local mount and resolves inside the project root; a remote server does not offer it at all and tells the caller to send bytes instead. `key` is required with `base64` and defaults to `assets/<filename>` with `path`. Existing keys refuse unless `overwrite: true`, because the store keeps no version history. ## Functions | Tool | Input | Notes | | --- | --- | --- | | `run_function` | `name`, `input?`, `approval?`, `authorization?` | Runs a typed function through the ordinary handler | There is no MCP-level scope on `run_function`. The function's own `access` rule, the anonymous-mutation default, its rate limit, and its approval gate all decide, exactly as they would over HTTP. `authorization` overrides the connection's bearer for one call, which is how an agent acts as someone else. On stdio the server holds `GRAFT_DEV_TOKEN` as the default authorization, so the secret never enters the agent's context. ## Operations | Tool | Input | Notes | | --- | --- | --- | | `setup_status` | none | Local `graft mcp` only. The checklist `graft doctor` prints: each check's state, what was found, the fix, and the one thing to do `next` | | `list_branches` | none | Registered branches and their backends | | `list_compilations` | `branch?`, `limit?` | Recent projection trail rows; default 20, max 100 | | `list_approvals` | none | Pending approvals, the same data as the Studio's list | | `decide_approval` | `id`, `decision` | Approve or deny | `decide_approval` needs the `approvals:decide` scope **and** an authenticated connection. The decision is attributed to the identity the connection authenticated as, and there is no argument to name a different decider. A requester can never decide its own approval. It also needs a database role that may `UPDATE approvals`. A hardened runtime role cannot, by design. See [Hardening](/docs/hardening). `graft mcp` does **not** have `approvals:decide`, on purpose. The agent files the approval and a person decides it with `graft approve`, and those have to be different identities. ## Packages, registry and errors | Tool | Input | Notes | | --- | --- | --- | | `list_packages` | `framework?`, `tier?`, `includeIndirect?` | Which `@usegraft/*` package to install, and when | | `list_registry` | none | Owned primitives available to `graft add` | | `describe_item` | `name` | One primitive's files and dependencies | | `explain_error` | `code?`, `error?` | What a GraftError means and how to recover | `list_packages` answers the question that comes before a project exists: an agent that knows the user is on SvelteKit can name `@usegraft/sdk-sveltekit` rather than guess. Filter by `framework` and you get that adapter plus the packages every project needs, never a competing adapter. Filter by storage engine (`tier`) and a static project is never told to install something that cannot work without Postgres. Distinct from `list_registry`: packages come from npm, primitives are copied into your repository by `graft add`. `explain_error` with no arguments lists every code this build knows. See the [Error reference](/docs/errors) for the same content as a page. ## Annotations Every tool declares MCP annotations, so a client can tell a read from a deletion without parsing prose. | Annotation set | Tools | | --- | --- | | Read-only | The four discovery tools, the three content reads, `list_branches`, `list_compilations`, `list_approvals`, `list_packages`, `list_registry`, `describe_item`, `explain_error` | | Write, not destructive | `write_content`, `publish_drafts` | | Destructive | `delete_content`, `discard_drafts`, `put_asset`, `decide_approval`, `run_function` | `write_content` is marked non-destructive, because Git can undo a content change once the earlier version is committed. Uncommitted edits it overwrites, and new files never committed, cannot be recovered from Git. `put_asset` is not, because the asset store keeps no history. `run_function` is marked destructive, because it runs whatever your function does. `openWorldHint` is false on every tool except `run_function`. The others work inside your own project: its content, its registry, and its error catalog. `run_function` runs a function you wrote, and that function can call any service, so it sets `openWorldHint: true`. > **Annotations are hints, not security.** > Annotations are hints. The MCP spec says a client must not make an > authorization decision on annotations from a server it does not trust, and > that is right. Graft's real gates are the scope check, the one-shot > input-bound approval, and the Postgres role separation. ## Output schemas Every tool except two declares an `outputSchema`. The SDK validates every result against it before the answer leaves the server, so a tool that quietly changes shape fails there rather than inside whatever the agent did next. Results also carry `structuredContent` alongside the text block. The two without one are `run_function`, which returns whatever your function returns, and `delete_content`, whose result includes the deleting function's own return value. Their shape depends on your code, so a fixed schema could not describe them. ## Scopes These tools check a scope at the MCP layer. `publish_drafts` also reads `content:publish` to decide between a pull request and a commit. | Scope | Tools | | --- | --- | | `content:write` | `write_content`, `delete_content`, `put_asset`, `publish_drafts`, `discard_drafts` | | `approvals:decide` | `decide_approval` | Everything else is ungated at this layer, and `run_function` defers to the function. A token without the scope gets `UNAUTHORIZED`, naming the scope required and the scopes it has. A server that allows anonymous callers checks no scopes at all, which is why anonymous MCP is only allowed on your own machine by default. A server given an identity resolver but no `connectionActor` refuses to start with `CONFIG_INVALID`. Without that check, it would pass every scope check without verifying anything. ## Static projects Tools that need Postgres stay listed on a static project and answer `NEEDS_DATABASE` with the upgrade rather than vanishing. `delete_content` and `list_compilations` refuse for reasons covered in [Storage engines](/docs/tiers#agents-on-the-static-engine). ## Call a tool from the terminal Every tool also runs from the terminal, on the same server `graft mcp` gives an agent and with the same checks. `graft mcp tools` lists the tools, and `graft mcp call` runs one by name and prints its answer: ```sh graft mcp call list_content --input '{"collection":"pages"}' ``` The tools you reach for most have their own commands. With `--json`, each prints exactly what the tool returns. | Tool | Command | | --- | --- | | `list_collections`, `list_content` | `graft content list [collection]` | | `get_content` | `graft content get <collection>/<slug>` | | `search_content` | `graft content search <query>` | | `write_content` | `graft content write <collection>/<slug>` | | `delete_content` | `graft content delete <collection>/<slug>` | | `list_drafts`, `publish_drafts`, `discard_drafts` | `graft drafts`, `graft drafts publish`, `graft drafts discard` | | `describe_schema` | `graft schema` | | `list_functions`, `describe_function`, `run_function` | `graft fn`, `graft fn describe`, `graft fn run` | | `explain_error` | `graft explain <code>` | See [Script the CLI](/docs/cli#read-and-write-content-from-the-terminal). ## Next steps - [Connecting agents](/docs/connecting-agents) covers transports, identity, and setup. - [MCP resources & prompts](/docs/mcp-resources-and-prompts) covers the rest of the surface. - [Agents overview](/docs/agent-surface) explains what agents can do. - [Error reference](/docs/errors) lists every code a tool can return. --- <!-- source: https://graft.page/docs/mcp-resources-and-prompts --> <!-- section: Agents & MCP --> # MCP resources & prompts > Attach documents to an agent's conversation, and start ready-made workflows filled in from your project. *Attach documents and workflows to an agent, not just tools.* Besides [tools](/docs/mcp-tools), MCP has two other features that Graft uses: - **Resources** are documents an agent's client can attach to the conversation directly, like attaching a file to an email. You pick a page, and the agent sees it, without the agent spending a step fetching it. - **Prompts** are ready-made workflows you pick from a menu in your client, such as "write a new document" or "fix this error". Graft fills each one in with real details from your project. Most clients show resources as attachable items and prompts as slash commands or a menu. ## Attach a document Every document has an address: ```text graft://<branch>/<collection>/<slug> ``` For example, `graft://main/pages/pricing`. Your client lists these, so you can attach a page to the conversation and ask the agent to revise it. Resources are read from your files, not from the index. So an attached document shows your latest edit even before you compile, and resources work on a static project with no database. The text is exactly what is in the file. When an agent lists, reads, searches, or writes content with a tool, each result also carries a link to the document's resource, so the agent can open it in one step. ### Attach your whole schema ```text graft://<branch>/schema ``` This attaches every collection and its fields, the same as the `describe_schema` tool. Attach it once at the start of a session instead of having the agent call the tool each time. ## Start a workflow from a prompt | Prompt | You provide | The agent gets | | --- | --- | --- | | `author-document` | A collection and a topic | That collection's real fields, with required ones marked | | `revise-document` | A collection, a document, and a goal | The document's address and the collection's fields | | `fix-error` | An error code | What that code means in this version of Graft, its usual causes, and how to recover | | `plan-migration` | A collection and the change you want | The current fields, and the steps to migrate safely | Some prompts also carry this project's working rules, where they apply: - `author-document` and `revise-document` give the agent the collection's real fields before it writes. `author-document` also tells it to read a schema failure's `fix` and correct the data, not retry unchanged. - `plan-migration` tells it to propose the migration as a reviewable commit, not a live edit, and never to run `graft migrate --apply` without asking. Applying is your decision. No prompt covers approvals, and none needs to: the server enforces them, whatever the agent was told. Over MCP, a destructive call from an identified caller files an approval request and stops until a person decides. An anonymous caller is refused with `UNAUTHORIZED` before anything is filed. The `"unattended"` approval policy, which removes the wait, applies to HTTP function calls only, never to MCP. `fix-error` with a code Graft does not know says so, and points the agent at `explain_error` to list the codes it does know. When the general explanation and the failing call's own `fix` disagree, the prompt tells the agent to follow the `fix`, because it is specific to that failure. ## Autocomplete When you fill in a prompt or a resource address, your client can suggest values from your project: | Argument | Suggests | | --- | --- | | `collection` | Your content collections. Operational data collections are left out, because they have no documents to write. | | `slug` | Documents in the collection you chose, or all documents if you have not chosen one | | `code` | Error codes, ignoring case | ## Where they are available Resources and prompts work the same over stdio and HTTP. The [public docs endpoint](/docs/docs-mcp) serves document resources but not the schema resource, which would reveal your functions, and no prompts, because they are instructions for writing to your project. ## Next steps - [MCP tools](/docs/mcp-tools) lists every tool. - [Connecting agents](/docs/connecting-agents) gets an agent connected. - [Public docs MCP](/docs/docs-mcp) covers the read-only endpoint. --- <!-- source: https://graft.page/docs/docs-mcp --> <!-- section: Agents & MCP --> # Public docs MCP > A second, read-only MCP endpoint for your documentation, separate from the private one. *Let anyone's agent read your published docs, with no login.* If your Graft project hosts documentation, people will ask their coding agents about it. A public docs endpoint lets those agents search and read your docs directly, instead of scraping web pages. It is read-only and needs no authentication. It is a separate server from your project's main MCP endpoint, not a setting on it: | Endpoint | Surface | Auth | | --- | --- | --- | | `POST /api/mcp` | Everything an agent can do | Required | | `POST /mcp` | Documentation only | None | ## Why a separate endpoint Docs platforms already publish this shape, and agents arrive expecting it. Mintlify generates a docs MCP at `/mcp` for every site it hosts: public, unauthenticated, strictly read-only. Cloudflare runs a documentation server separately from its authenticated API server. Keeping it separate also keeps it safe. If it were a flag on the main endpoint, which also handles writes, one wrong setting could publish more than your docs. As its own server, it can only ever serve the tools listed below. ## What it serves Five tools and the document resources. | Tool | Purpose | | --- | --- | | `list_collections` | What kinds of documents exist | | `list_content` | The documents in one collection | | `get_content` | One document's frontmatter and body | | `search_content` | Full-text search across the published content | | `explain_error` | What a Graft error code means | Documents are also addressable as `graft://<branch>/<collection>/<slug>` resources, so a client can attach a page rather than paste it. ## What it leaves out, and why It leaves out every write, as you would expect. It also leaves out several reads, because they reveal how your project works rather than what your docs say: | Left out | Reason | | --- | --- | | `describe_schema` | Carries the project's functions | | `list_registry`, `describe_item` | Carry the project's owned primitives | | `list_branches`, `list_compilations`, `list_approvals` | Carry the project's operations | | `graft://<branch>/schema` | Same payload as `describe_schema` | | Prompts | Three of the four instruct `write_content`, which is not here | | `write_content`, `delete_content`, `put_asset`, `run_function`, `decide_approval` | Writes and execution | So the test for what belongs on a public endpoint is not "is it read-only?" but "is it documentation?". ## Set one up Create the handler with `createDocsMcpHandler` and mount it at `/mcp` on your docs domain, which is where agents look. This is the route this site runs, on the static engine: `src/pages/mcp.ts` (Astro): ```ts import type { APIRoute } from "astro"; import { resolve } from "node:path"; import { createDocsMcpHandler, type GraftMcpHandler } from "@usegraft/mcp"; import { collections } from "../../graft.config"; import { staticIndexPath } from "../lib/graft"; export const prerender = false; let handler: GraftMcpHandler | null = null; function getHandler(): GraftMcpHandler { handler ??= createDocsMcpHandler({ name: "graft-docs", contentDir: resolve(import.meta.dirname, "../..", "content"), collections, staticIndexPath, // or db: createDb(url).db on Postgres }); return handler; } export const POST: APIRoute = ({ request }) => getHandler()(request); export const GET: APIRoute = ({ request }) => getHandler()(request); ``` `staticIndexPath` is the same absolute path your app's own reads open, imported from where you resolve it. Avoid a relative path such as `".graft/index.db"`: it is resolved from the working directory, and on a serverless host that is not your project folder, so the handler would not find the index. Export both `POST` and `GET`. Agents only send `POST`, but a `GET` should answer `405` rather than `404`, which tells a client the endpoint exists and only accepts `POST`. ### Options it does not accept The docs handler only accepts `contentDir`, `collections`, `db`, `staticIndexPath`, `branchId`, `scope`, `name`, and `version`. It has no option for functions, identities, file uploads, or approvals, so they cannot be turned on by mistake. Even if JavaScript code passes `functions` anyway, the handler ignores it. Setting up the public endpoint can never expose the private one. ## Choose what is published The server publishes the collections you hand it. To publish your docs but not your product catalog, pass only the collections you mean: ```ts createDocsMcpHandler({ contentDir, collections: { docs }, db, }); ``` > **Drafts are published too.** > The endpoint reads your files directly. Anything in a published collection's > folder is readable, including drafts you have not linked from any page. ## Other ways agents can read your docs An MCP endpoint is one of several ways an agent can read documentation without scraping rendered HTML. This site also serves: | Surface | What it is | | --- | --- | | `/llms.txt` | An index of pages with descriptions and `.md` links | | `/llms-full.txt` | Every page's content in one file | | `/docs/<slug>.md` | Any page as markdown | They are generated from the same content and grouping as the sidebar, so they always match the site. Each page also answers `Accept: text/markdown` with its Markdown version. > **In the dev server.** > `/llms.txt` is shadowed under `astro dev`, because Vite serves the project > root as static and a Graft project has an `llms.txt` at its root. Production > is correct. Verify that route with `astro build`, not the dev server. ## Next steps - [Connecting agents](/docs/connecting-agents) documents the private endpoint. - [MCP tools](/docs/mcp-tools) lists the full tool surface. - [Agents overview](/docs/agent-surface) explains what agents can do. --- <!-- source: https://graft.page/docs/deploy --> <!-- section: Deploy & operate --> # Deploy > Ship a static Graft site with your app, or run Graft on Postgres inside your app or as its own server, on your own infrastructure. *Run Graft on your own infrastructure. There is no Graft cloud to sign up for.* How you deploy depends on your [storage engine](/docs/tiers): - **Static projects** deploy like any other site. There is no Graft server. - **Postgres projects** also need the database, file storage if you use assets, and somewhere for Graft's endpoints to run. ## Deploy a static project Compile in your build command, so the index is built from the files in the commit being deployed: ```sh graft compile && next build ``` Then make sure `.graft/index.db` ships with your app. Most hosts include every file in the build output. Vercel traces imports, not data files, so name it explicitly if a server function reads it (this site does this, in `astro.config.mjs`): ```js adapter: vercel({ includeFiles: [".graft/index.db"] }), ``` That is the whole deployment. Pages that prerender at build time do not need the file at runtime at all. ## Choose where Graft runs on Postgres Graft's endpoints (typed functions, the MCP endpoint for agents, the Content API, and a health check) can run in two places. They are the same code either way. | | Inside your app | As its own server (`graft serve`) | | --- | --- | --- | | How | Your framework mounts Graft's handlers as routes | A Node server runs the endpoints for you | | Choose it when | You already deploy a Next.js, Astro, SvelteKit, TanStack Start, or React Router app | You want a separate content backend, or your frontend cannot open the index | | Setup | Your [framework guide](/docs/next#serve-functions-and-mcp-from-your-app) | One command, or the container below | `graft serve` exposes: | Route | What it is | | --- | --- | | `POST /api/fn/<name>` | [Typed functions](/docs/functions-and-access) | | `POST /api/mcp` | The [agent endpoint](/docs/connecting-agents) | | `GET /api/content/v1/documents`, `GET /api/content/v1/search` | The [Content API](/docs/content-api), for frontends with no index of their own | | `GET /healthz` | Health, with the branch and Git commit being served | > **The Content API is public.** > The two content routes check no identity and serve every collection in your > config. Their rate limit stops one caller from exhausting the server, but it > is not access control. If the content should not be public, put a proxy in > front that checks. See [The content API](/docs/content-api#it-does-not-authenticate-requests). ## Run the container The quickest way to self-host is the Graft container. It bundles Postgres and file storage, runs migrations, compiles your content, and serves. During the beta the image is not published, so build it from the repository first: ```sh docker build -f deploy/docker/Dockerfile -t graft . ``` Then run it with your project mounted at `/project`, and a named volume each for the database and the uploaded files, so both survive a new container: ```sh docker run --init -p 3903:3903 -v ./my-site:/project -v graft_pg:/var/lib/postgresql -v graft_minio:/data graft ``` `GET /healthz` reports the branch and Git commit it is serving. The container also creates a [hardened](/docs/hardening) database role at start and serves with it. Full recipes, including split `db` / `storage` / `graft` compose files, are under `deploy/` in the repository. ## Where to host it | Host | How | Notes | | --- | --- | --- | | Railway | The container | With managed Postgres and R2 for assets | | Fly.io | The container on Fly Machines | Tigris or R2 for assets | | A VPS | Split compose (`db`, `storage`, `graft`) | Behind your own reverse proxy | | Vercel | Inside your app | Compile is a build step. This docs site runs this way. | ## Set up database credentials A Postgres deployment should use two database logins: | Login | Where it lives | Used for | | --- | --- | --- | | **Operator** | Your machine or CI | `graft compile`, `graft migrate --apply`, `graft merge`, `graft approve` | | **Runtime** | The deployment's `DATABASE_URL` | Serving reads, running functions, agent content writes, requesting approvals | The runtime login cannot approve destructive actions, so a leaked app password cannot approve its own deletes. [Hardening](/docs/hardening) sets it up in four steps. The container does it for you in all-in-one mode, and in `GRAFT_MODE=serve` once you set `GRAFT_HARDEN=1` or `GRAFT_RUNTIME_PASSWORD`. ## Next steps - [Hardening](/docs/hardening) sets up the two database logins. - [Configuration](/docs/configuration) lists every environment variable. - [Observability](/docs/observability) covers health checks and tracing calls. - [Host Studio for your team](/docs/studio-hosting) lets editors sign in and publish from a browser, with every change committed to GitHub. --- <!-- source: https://graft.page/docs/hardening --> <!-- section: Deploy & operate --> # Hardening > Give your deployed app a database login that can serve your site but can never approve its own destructive actions. *Limit what your deployed app's database login can do.* > Needs the Postgres storage engine. Your deployed app connects to Postgres with a username and password. If that password leaks, or a bug in your app misuses it, whoever holds it can do anything that database user can do. That includes approving its own deletions, which would defeat [human approvals](/docs/audit-and-approvals). `graft harden` fixes this by splitting access between two database logins: - The **runtime role** is what your deployed app uses. It can serve content, run functions, and request approvals, but it can never grant one. - The **operator role** stays on your machine or in CI. It compiles, migrates, merges, and approves. This page sets that up in four steps, then explains exactly what it does and does not protect. ## Harden a deployment **Create an empty role for your app** Connect as your usual admin user and create a new role with no privileges: ```sql CREATE ROLE graft_runtime LOGIN PASSWORD '…'; ``` It must start empty. The next step only adds permissions, it never removes any, so a role that already has rights keeps them. **Grant it Graft's permissions** With the **operator** `DATABASE_URL` set, run: ```sh graft harden graft_runtime ``` It prints every statement it applied. If the role does not exist yet, it stops with the `CREATE ROLE` line you need. **Check that approvals are locked** Connect as `graft_runtime` and try to approve everything: ```sql UPDATE approvals SET status = 'approved' WHERE status = 'pending'; ``` You should get a permission error. If it succeeds, the role had rights before you started. Check it is not a superuser and has no `UPDATE` on `approvals`: ```sql SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'graft_runtime'; SELECT has_table_privilege('graft_runtime', 'approvals', 'UPDATE'); SELECT has_column_privilege('graft_runtime', 'approvals', 'status', 'UPDATE'); ``` All four should come back false. Both checks count rights the role inherits from any group it belongs to, not only rights granted to it by name. The second also catches an `UPDATE` granted on the `status` column alone, which is enough to approve. **Deploy with the runtime role** Set your deployment's `DATABASE_URL` to the `graft_runtime` login. Keep the operator URL out of the deployment, and use it only for `graft compile`, `graft migrate --apply`, `graft merge`, and `graft approve`. ## What your app can still do Everything a running site needs, so hardening costs you no features: | Table | The runtime role may | | --- | --- | | Every Graft table | Read | | `data_records` (operational data) | Add, change, and remove rows | | `content_index` | Add and update documents, so agent writes can recompile | | `compilations` | Add rows | | `audit_log` | Add rows, and fill in a call's status and duration | | `approvals` | File a request, using seven specific columns only | It can also run `graft_consume_approval`, a database function that marks an approved request as used. That is how an approved call goes through. ## What your app can never do - **Change the schema.** No new tables, no migrations, no branch create or merge. - **Approve anything.** It cannot change a request from pending to approved. - **File a request that is already approved.** It can only write the columns a request needs (`branch_id`, `function_name`, `input`, `input_canonical`, `requested_by_kind`, `requested_by_id`, `correlation_id`). The `status` column is not one of them, so every new request starts as pending. The last two only work together. If the role could insert the `status` column, it could skip the approval step entirely by filing a request marked approved. Every decision also records which database login made it, taken from the database itself, so the audit trail shows which credential approved a request, not which one claimed to. ## What hardening does not protect Be precise about this one. The runtime role can update `content_index`, because agent writes recompile content and compiling writes there. So someone with a stolen runtime password could hide or rewrite documents with raw SQL. That means the approval on `delete_content` protects you from an agent misusing the tool, not from a stolen password. No set of permissions short of forbidding content writes entirely could change that. Approvals for destructive functions are different. Those are enforced by the database, so they hold even against a stolen runtime password. That is the guarantee hardening adds. Your content is also still safe in Git. Recompiling restores the index. ## What changes after hardening Some actions now need the operator login. That is the point: | Action | On a hardened deployment | | --- | --- | | `graft approve` / `graft deny` | Operator login only | | Approving over MCP (`decide_approval`) | Refused, so agents cannot approve | | Approve and deny in Studio | Refused, so the approvals view is read-only | | Approvals asked inside the agent's session | Always refused | | An agent writing content | Still works | | `graft migrate --apply` | Operator login only | ## The Graft container does this for you The self-host container creates and hardens a runtime role when it starts, then serves with it, because in that setup the container owns its database. | Mode | Hardening | | --- | --- | | All-in-one | On by default | | `GRAFT_MODE=serve` (your own database) | Off, until you set `GRAFT_HARDEN=1` or `GRAFT_RUNTIME_PASSWORD` | In `serve` mode the database is yours, so the container does not create roles in it unless you ask. [Configuration](/docs/configuration#serve) lists these variables. ## Next steps - [Audit & approvals](/docs/audit-and-approvals) explains the approvals this protects. - [Deploy](/docs/deploy) shows where each login goes. - [Studio](/docs/studio) lists approvals under Developer, read-only on a hardened deployment. --- <!-- source: https://graft.page/docs/branching-previews --> <!-- section: Deploy & operate --> # Branching & previews > Preview content changes on a branch, show them to reviewers, then merge. Production stays untouched until you do. *Show reviewers a content change before it reaches production.* > Needs the Postgres storage engine. You are redesigning the pricing page. The new copy is on a Git branch, but your reviewers do not want to read a diff. They want to click through the real site with the new pricing in place, while visitors keep seeing the current version. A **Graft branch** gives you that. It is a separate copy of the content index, built from the files on your Git branch. Your app can read from it instead of the main index, so a preview deployment shows exactly the branch's content. When the change is approved, you merge it. ## How Git branches and Graft branches fit together They do different jobs, and you use both: - **The Git branch holds the files.** You edit, commit, and review content there, as usual. - **The Graft branch holds the compiled index** for those files, so an app can read and render them. You create a Graft branch, compile your Git branch's files into it, and point a preview at it. Merging happens in the same order: merge the Git branch first, then `graft merge` the Graft branch. `graft merge` never touches Git. Branches need the [Postgres engine](/docs/tiers). On the static engine every Git checkout already compiles its own index file, so there is nothing extra to create. ## Create a branch and preview it **Check out your Git branch and create a Graft branch** ```sh git switch pricing-redesign graft branch create preview-pricing ``` The new branch starts empty and copies nothing. Any document you have not changed is read from the main index, which is why creating a branch is instant and dropping one is safe. **Compile your files into the branch** ```sh graft compile --branch preview-pricing ``` This is the same compile you already run, written into the branch instead of `main`. Run `graft branch list` to see every branch. **Read the branch from your app** Pass `branch` to any read. Single documents, lists, and search all respect it: `app/pricing/page.tsx`: ```tsx import { graft } from "@/lib/graft"; const branch = process.env.GRAFT_PREVIEW_BRANCH; // "preview-pricing" on the preview deploy export default async function Pricing() { const page = await graft.getContent("pages", "pricing", { branch }); return <h1>{page?.data.title}</h1>; } ``` On the preview deployment, set `GRAFT_PREVIEW_BRANCH=preview-pricing`. In production, leave it unset and reads use `main`. To throw a branch away, drop it. `main` keeps no trace of it: ```sh graft branch drop preview-pricing ``` ## Choose where a branch lives Graft can store a branch in two ways. Both answer the same commands. | | Overlay (default) | Neon fork | | --- | --- | --- | | How it works | The branch's documents are stored in your existing database, labelled with the branch name | A full copy of the database, made instantly by Neon | | Cost | Instant, copies nothing | Instant, but needs a Neon project | | Use it for | Content previews and review | Rehearsing destructive changes, or previews that must not be able to change production | Create a Neon fork with `--backend neon`: ```sh graft branch create big-redesign --backend neon ``` > **How an overlay hides a deleted page.** > If you delete a page on a branch, Graft stores a small marker saying "hidden > on this branch", called a **tombstone**. Reads on the branch skip that page. > Dropping the branch removes the marker, so `main` is unaffected. ## What a branch copies The two backends differ here, and it matters if production holds personal data. - **Overlay.** The branch lives in your existing database, but operational data (form submissions, orders) is stored per branch, so the branch starts with none of `main`'s. - **Neon fork.** The fork starts as a full copy of the database. Graft then empties the operational data (`data_records`) and the approvals, but nothing else. The audit log, including who did what, and any tables of your own come across as they are. Treat a Neon fork as holding production data. Give it the same access controls as production, and do not share its credentials more widely. ## Merge a branch > **Run a dry run first.** > A merge writes the branch's changes into its parent. Always run it without > `--apply` first and read the report, especially before merging into `main`. 1. Merge the Git branch, so the files and any migration files are on `main`. 2. Run the merge as a dry run, and read what it will change: ```sh graft merge preview-pricing ``` 3. Apply it: ```sh graft merge preview-pricing --apply ``` Applying does three things, in order. It replays any [migrations](/docs/migrations) that ran on the branch but not on the target. It moves rows your users created on the branch to the target. Then it recompiles the target from your files. ## Keep cached pages fresh Every compile reports which documents changed. The framework packages turn that into cache tags, so only the affected pages are refreshed, and a preview branch never clears `main`'s cache. [SDK reference](/docs/sdk-reference#cache-tags) lists the tag helpers. ## Next steps - [Migrations](/docs/migrations) covers schema changes, which merges replay. - [Deploy](/docs/deploy) covers running a preview deployment. - [Operational data](/docs/operational-data) explains the user data a branch does not copy. --- <!-- source: https://graft.page/docs/migrations --> <!-- section: Deploy & operate --> # Migrations > Change your schema without breaking content and data written in the old shape: rewrite files, update rows, and record what ran. *Change your schema without breaking the content and data you already have.* Sooner or later you rename a field, split one into two, or add a required one. The schema change itself is one line. The real work is updating everything already written in the old shape, so it still passes the new schema. A Graft migration is a TypeScript file that does that update. There are two kinds, depending on where the old data lives: | Kind | Updates | Use it for | | --- | --- | --- | | **Content migration** (a "codemod") | Your Markdown files in Git | Normal collections | | **Data migration** (a "backfill") | Rows in Postgres | [Operational data](/docs/operational-data) collections | This page walks through one of each, then covers how they run and how they interact with renames and branches. ## Example: add a required field to your pages You add a required `description` field to `pages`. Every existing page lacks it, so the next compile would fail. Write a content migration that fills it in from the tagline: `migrations/0001-pages-description.ts`: ```ts import type { DocumentData } from "@usegraft/core"; import { defineContentMigration } from "@usegraft/content-migrations"; import { pages } from "../graft.config"; type Page = DocumentData<typeof pages>; export default defineContentMigration({ collection: pages, description: "Fill in description from tagline", transform: (doc) => ({ data: { // Start from every field the file has. A field left out of `data` is // removed from the file, so listing fields one by one drops the rest. ...(doc.data as Omit<Page, "description">), description: String(doc.data.tagline ?? ""), }, }), }); ``` `transform` receives each document's frontmatter (`doc.data`) and body (`doc.body`) as they are on disk, in the old shape. It returns the new frontmatter, and a new `body` if you want to change that too. The old frontmatter is untyped, because it no longer matches your schema. The example keeps every existing field by spreading `doc.data`, cast to the fields it already had, and adds the new one. TypeScript checks the new field against your current schema, and the run checks every rewritten file against the schema before writing any of them. The migration changes the **files**, not the index. Changing only the index would be undone by the next compile, which rebuilds the index from the files. ## Run it ```sh graft migrate # list what would run, change nothing graft migrate --apply # run it ``` A content migration checks every rewritten file against the current schema before writing any of them. If one would fail, none are written. When it succeeds, review the diff with `git diff` and commit it like any other change. Migrations run in file-number order. Graft records each one it applies in a table called the **ledger** (`migrations_applied`), and later runs skip it. `graft migrate --apply` takes a lock on the branch before it reads the ledger. If two deploys start a run at the same moment, the second waits for the first to finish, then skips every migration the first one applied. `graft merge --apply` takes the same lock on both the branch it merges and the branch it merges into. The lock holds one database connection for the whole run, so the pool needs at least two. ## Example: tidy up existing rows For operational data, use `defineDataMigration`. This one lowercases every stored email address: `migrations/0002-lowercase-emails.ts`: ```ts import { defineDataMigration } from "@usegraft/core"; import { submissions } from "../graft.config"; export default defineDataMigration({ collection: submissions, description: "Normalize emails to lowercase", transform: ({ data }) => ({ ...data, email: String(data.email).toLowerCase() }), }); ``` The row updates and the ledger entry are saved in one transaction. Either both happen or neither does. ## After renaming or deleting a collection If you rename or delete a whole collection, the index still holds documents under the old name. Graft does not recognize them, so the next compile stops with `INDEX_OWNERSHIP` rather than deleting content it does not know about. When the rename is intentional, compile once with `--prune-unknown` to remove the old documents: ```sh graft compile --prune-unknown ``` Use it only for this. Normally that error means something is wrong, such as two projects sharing one database. ## Migrations and branches When you [merge a preview branch](/docs/branching-previews#merge-a-branch), `graft merge` first replays any migrations that ran on the branch but not on the target, then moves the branch's data rows. Merge the Git branch first: a content migration's rewritten files arrive through that Git merge, and `graft merge` only records its ledger entry. ## Next steps - [Schema](/docs/schema) covers declaring fields. - [Branching & previews](/docs/branching-previews) covers merging. - [Operational data](/docs/operational-data) explains the rows a data migration updates. --- <!-- source: https://graft.page/docs/observability --> <!-- section: Deploy & operate --> # Observability > Trace a user's problem to the exact call, see what each compile changed, and check that your deployment is healthy. *Find out what happened when a user reports a problem.* > Needs the Postgres storage engine. A user says the contact form failed. This page walks you from that report to the exact call, who made it, which version of your code handled it, and how it ended. Then it covers spotting patterns across many calls, seeing what each compile changed, and health checks. Everything here is stored in ordinary Postgres tables, so you can read it with SQL. The parts an agent needs are also available over MCP and the CLI. ## Trace one failed call **Get the correlation id** Every function response carries an `x-graft-correlation-id` header, whether the call worked or failed: ```sh curl -i -X POST https://cms.example.com/api/fn/submitContact \ -H 'content-type: application/json' \ -d '{"email":"a@b.co"}' # x-graft-correlation-id: 4f1c… ``` Show it in your app's error message, or log it, so a user's report can include it. **Look it up in the audit log** ```sql select * from audit_log where correlation_id = '4f1c…'; ``` The lookup is indexed, so it is fast even on a large log. **Read the row** The row tells you who called, what ran, how it ended, and which commit of your code served it. The next section lists every column. ## What the audit log records One row for every function call, whatever the outcome: | Column | What it holds | | --- | --- | | `correlation_id` | The id returned in the response header | | `function_name`, `function_kind` | What ran | | `actor_kind`, `actor_id` | Who ran it. `actor_id` is `NULL` for anonymous callers | | `status` | `"ok"`, or the error code it failed with | | `duration_ms` | How long it took | | `git_sha` | The commit of the code that served it | | `branch_id` | Which content branch the call ran on | | `rate_key` | What the rate limit counted: the caller's id, or their IP | | `created_at` | When | The inputs are not recorded, so the log never holds a copy of your users' data. If you need the exact input of a call that waited for approval, it is on that call's row in the `approvals` table. A row is written even when the call is refused, for example for bad input or a missing permission. Graft writes the row as soon as it knows who is calling, then fills in the result. `git_sha` comes from `VERCEL_GIT_COMMIT_SHA` or `GITHUB_SHA` when your platform sets them, and `graft serve` reads it from the local checkout. It answers "which deploy did this?" without searching your logs. ## Spot patterns across calls Because refused calls are logged too, `status` shows how your functions fail. For example, the errors from the last day: ```sql select status, count(*) from audit_log where created_at > now() - interval '1 day' group by status order by count(*) desc; ``` ## See what each compile changed The `compilations` table records every compile: the branch, the Git commit it read, the number of documents, and how many it added, changed, and removed. | Where | How | | --- | --- | | CLI | `graft compilations` | | MCP | `list_compilations` | | Studio | Developer, then History | | HTTP | `GET /api/studio/v1/compilations` | Each row records the Git commit it compiled, so you can restore your content to exactly how it was then. See [Developer views](/docs/studio#developer-views) and the revert routes in the [Studio API](/docs/studio-api#routes). ## Review past approvals The `approvals` table is a record in its own right. Every destructive call that waited for approval has a row with who asked, the exact input, the decision, and who decided. Which database login made the decision is recorded by the database itself, so it cannot be faked by the app. See [Hardening](/docs/hardening). ## Check the server is healthy `graft serve` answers `GET /healthz`. It makes a real round trip to the database before answering: ```json { "ok": true, "branch": "main", "collections": 3, "functions": 2, "gitSha": "d7aa173…" } ``` If the database does not answer, it returns `503` with `{ "ok": false, "error": "…" }`. Point your platform's health check at it. The `gitSha` field also confirms which build is live. ## Keep the log a manageable size Graft never deletes audit rows. On a busy site the table grows, and rate limits only read recent rows. Delete old ones on your own schedule, keeping whatever your compliance rules require: ```sql delete from audit_log where created_at < now() - interval '90 days'; ``` Run it with the operator login. A [hardened](/docs/hardening) app login cannot delete audit rows, on purpose. ## What Graft leaves to your platform - **Logs.** Graft writes to standard output. Collect it the way your host does. - **Metrics.** There is no metrics endpoint. The audit log is the source, so aggregate it with SQL or export it. - **Tracing.** Graft does not emit traces. Pass the correlation id into your own spans if you use tracing. If writing an audit row fails, Graft logs the failure with the correlation id and still completes the call. Failing a user's request because the record could not be written would be worse than missing one record. ## Next steps - [Audit & approvals](/docs/audit-and-approvals) explains what the log is for. - [Rate limits](/docs/rate-limits) covers the other job these rows do. - [Deploy](/docs/deploy) shows where `/healthz` lives. --- <!-- source: https://graft.page/docs/powered-by-graft --> <!-- section: Deploy & operate --> # Powered by Graft > A copy-paste badge for a README or footer. No package to install. *Credit Graft with a badge. Two lines of HTML.* Hosted SVGs and an `<a><img></a>`. That is the whole integration. It follows the same shape Vercel and Bolt publish. There is no npm package. The phrase is **Powered by Graft**. Two marks ship so the ink matches the paper: dark-on-light, and light-on-dark. ## HTML ```html <a href="https://graft.page?utm_source=powered-by-graft"> <img src="https://graft.page/badges/powered-by-graft.svg" alt="Powered by Graft" height="24" /> </a> ``` On a dark background, swap the `src` for `https://graft.page/badges/powered-by-graft-light.svg`. ## Markdown ```md [![Powered by Graft](https://graft.page/badges/powered-by-graft.svg)](https://graft.page?utm_source=powered-by-graft) ``` ## React Import nothing. Paste the same markup: ```jsx <a href="https://graft.page?utm_source=powered-by-graft"> <img src="https://graft.page/badges/powered-by-graft.svg" alt="Powered by Graft" height={24} /> </a> ``` This site uses the same files in the landing footer and the docs chrome. `graft init` does not drop the snippet into a new project yet. --- <!-- source: https://graft.page/docs/studio-hosting --> <!-- section: Deploy & operate --> # Host Studio for your team > Run Studio on a server so editors can sign in, draft and publish from a browser, with every change landing in Git on GitHub. *Give a content team a Studio URL. Their changes still end up as commits.* > Needs the Postgres storage engine. 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. > **No GitHub needed.** > > If you would rather not set up GitHub at all, keep your content in the > database instead: `export const content = { source: "database" }` in > `graft.config.ts`. A hosted Studio then saves and publishes to `DATABASE_URL`, > and a publish is live without a redeploy. Steps 1 and 4 below don't apply. > Step 2 does: set `GRAFT_STUDIO_SECRET`, and people sign in with Studio > accounts. See [Configuration](/docs/configuration#content). > ## 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](/docs/configuration#content). ## 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`: ```sh 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_JOIN` joins on their own, with the domain's role. See below. - Someone without an account or an invitation gets in only if `GRAFT_STUDIO_EDITORS` lists 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: ```sh 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): ```ts 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: ```ts 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: ```sh 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](/docs/deploy), Fly, Railway or Render. The content directory can be read-only; nothing writes to it. ```sh 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](/docs/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](/docs/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](/docs/cli#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](/docs/studio#site-and-deploy-hooks). ## Check it works 1. Open `/studio`. You see the sign-in screen. 2. 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. 3. Press Publish. One commit appears on the production branch, authored by you, and your draft branch no longer holds changes. 4. 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. --- <!-- source: https://graft.page/docs/cli-reference --> <!-- section: Reference --> # CLI reference > Every graft command, its arguments and flags, grouped by task. 56 commands. *Every graft command, grouped by when you use it.* Look up a command here. In a terminal, `graft --help` lists the commands and `graft <command> --help` shows one command's usage and flags. To read graft's output from a script or an agent, see [Script the CLI](/docs/cli). This page is generated from the table the CLI's own help reads, so the two always match. `graft migrate` and `graft merge` only report what they would change until you pass `--apply`. ## Set up a project ### `graft create` Make a new site: a template, a database, Studio and (by default) a Next.js app, installed and ready. `npm create graft` runs this. It asks a few questions; flags answer them, and --yes takes the defaults for the rest. ```text graft create [dir] [--template <name>] [--edit online|code] [--database new|none|<url>] [--framework next|none] [--yes] [--no-install] [--no-input] ``` | Flag | What it does | | --- | --- | | `--template <name>` | blog, docs, shop, portfolio or blank (default: blog). | | `--edit online\|code` | online keeps content in the database, edited in Studio (default); code keeps it as files in git. | | `--database new\|none\|<url>` | new makes a free Postgres to claim later; none compiles content to a file (with --edit code); or your own Postgres 13+ URL. | | `--framework next\|none` | next for a Next.js site that shows every collection (default); none for only the content. | | `-y, --yes` | Use the default for every question a flag did not answer. | | `--no-install` | Write the files and stop before installing. | | `--no-input` | Never stop to ask a question. Same as `GRAFT_PROMPT_DISABLED`. | Needs no database. May ask questions on a terminal; `--no-input` never does. ```sh graft create my-site --template docs --edit code --database none --yes ``` ### `graft init` Scaffold a Graft project in dir (default: here). The static engine is the default: no database and no environment variables. ```text graft init [dir] [--static] [--postgres] [--json] ``` | Flag | What it does | | --- | --- | | `--static` | Static engine: no database, no env (the default). | | `--postgres` | Postgres engine: operational data, typed functions, database branching. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ### `graft doctor` Check this project's setup: the database, its schema, content, the index and Studio sign-in. Prints a checklist and the one thing to do next. ```text graft doctor [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Uses `DATABASE_URL` when it is set, and works without it. Prints its answer as JSON with `--json`. | Exit code | What it means | | --- | --- | | `0` | Everything is set up. | | `1` | Something is missing; the output says what to do next. | ### `graft add` Copy an owned primitive (and the primitives it needs) from the registry into graft/, and regenerate the graft/ barrel. graft.config.ts needs no edit. With no item, list the primitives you can add. ```text graft add [item...] [--dry-run] [--overwrite] [--json] ``` | Flag | What it does | | --- | --- | | `--dry-run` | Show what would change without changing it. | | `--overwrite` | Replace files that differ from the registry's. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ```sh graft add comments --dry-run ``` ## Write and check content ### `graft compile` Check every content file and build the index, once. ```text graft compile [--branch <id>] [--prune-unknown] [--json] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--prune-unknown` | Remove index entries in collections the schema no longer has (default: refuse, which protects a shared DATABASE_URL). | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ```sh graft compile --json > changes.json ``` ### `graft dev` Watch content/ and graft.config.ts, and compile on every change. ```text graft dev [--branch <id>] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Runs until you stop it. ### `graft check` Check every content file the way a compile or a publish would: the schema, slug uniqueness, MDX safety, and references to documents that don't exist. Needs no database. ```text graft check [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. | Exit code | What it means | | --- | --- | | `1` | At least one file has a problem; each is listed. | ### `graft content` List every document; the same as graft content list. ```text graft content [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft content list` List documents with their slug, title and file. Reads the content files, or the database in a project whose content is kept there. ```text graft content list [collection] [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft content get` Print one document: its frontmatter, then its body, the way the file reads. With --json, the collection, slug, file path, data and body. ```text graft content get <collection/slug> [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ```sh graft content get posts/launch > launch.mdx ``` ### `graft content search` Search with words, "quoted phrases", or and -exclusions. Searches the compiled index, so results are as fresh as the last compile. ```text graft content search <query...> [--branch <id>] [--collection <name>] [--limit <n>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--collection <name>` | Search one collection (default: all). | | `--limit <n>` | Most matches to print, best first (default: 20). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ```sh graft content search pricing "free tier" --collection pages ``` ### `graft content write` Write a document from an MDX file (--file), or from frontmatter as JSON (--data) and an optional body (--body-file). It is checked against the collection's schema and compiled, exactly as an agent's write_content is. A project whose content is kept in the database saves it as a draft. ```text graft content write <collection/slug> [--branch <id>] [--file <doc.mdx|->] [--data <json>] [--body-file <file|->] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--file <doc.mdx\|->` | The whole document as an MDX file; - reads it from stdin. | | `--data <json>` | The frontmatter as a JSON object; - reads it from stdin. | | `--body-file <file\|->` | The body, with --data; - reads it from stdin. | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ```sh graft content get posts/launch > launch.mdx graft content write posts/launch --file launch.mdx ``` ### `graft content set` Set one frontmatter field on each document named, each written as graft content write would. One document that fails does not stop the others. --value null removes the field. ```text graft content set <collection> <slug...> [--branch <id>] [--field <name>] [--value <json>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--field <name>` | The frontmatter field to set. | | `--value <json>` | The new value as JSON; text that is not JSON is taken as a string. | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. | Exit code | What it means | | --- | --- | | `1` | At least one document was not saved; each is listed. | ```sh graft content set posts launch roadmap --field tags --value '["2026"]' ``` ### `graft content delete` Delete a document. The first run files an approval and exits 3 with its id. After a person runs graft approve \<id>, run it again with --approval \<id>. ```text graft content delete <collection/slug> [--branch <id>] [--approval <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--approval <id>` | The approval a person granted for this delete. | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft compilations` List recent compiles and what each one changed. ```text graft compilations [--branch <id>] [--json] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft asset put` Upload a binary to the asset store (S3_* env) and print the frontmatter reference. The key defaults to assets/\<file name>. A key that already holds a file is refused with ASSET_EXISTS, because the store keeps no version history. ```text graft asset put <file> [key] [--overwrite] [--json] ``` | Flag | What it does | | --- | --- | | `--overwrite` | Replace the file already stored under the key. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ```sh graft asset put ./hero.png assets/hero.png ``` ### `graft drafts` List unpublished drafts, for content kept in the database. ```text graft drafts [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft drafts publish` Publish drafts by path (from graft drafts). On a site that reviews changes, this asks for a review instead. A path someone else published since is refused with CONTENT_CONFLICT; --resolve \<path>=mine or =theirs settles it. ```text graft drafts publish <path...> [--branch <id>] [--message <text>] [--resolve <path>=mine|theirs]... [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--message <text>` | The commit or review title. | | `--resolve <path>=mine\|theirs` | Settle a conflict on one path. Repeat it for more than one. | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft drafts discard` Throw away drafts, back to the published version. ```text graft drafts discard <path...> [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ## Look into the project ### `graft schema` Print each collection's fields, and the typed functions. ```text graft schema [collection] [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft fn` List the typed functions. ```text graft fn [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft fn describe` Print one function's input schema and access rules. ```text graft fn describe <name> [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft fn run` Run a function through the same checks as POST /api/fn/\<name>: input validation, access rules, rate limits, the audit log, and the approval a destructive function needs (exit 3, then --approval \<id>). ```text graft fn run <name> [--branch <id>] [--input <json|->] [--approval <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--input <json\|->` | The input as a JSON object; - reads it from stdin (default: \{}). | | `--approval <id>` | The approval a person granted for this call. | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft explain` Explain an error code: what it means and how to recover. ```text graft explain <code> [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ```sh graft explain SCHEMA_VALIDATION_FAILED ``` ### `graft mcp tools` List the MCP tools an agent sees for this project. ```text graft mcp tools [--branch <id>] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ### `graft mcp call` Call any MCP tool by name, as an agent would. ```text graft mcp call <tool> [--branch <id>] [--input <json|->] [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--input <json\|->` | The tool's input as a JSON object; - reads it from stdin (default: \{}). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. Prints its answer as JSON with `--json`. ```sh graft mcp call list_content --input '{"collection":"posts"}' ``` ## Run Graft ### `graft mcp` Serve the project MCP over stdio: content and function tools, for .mcp.json and other local agents. On the Postgres engine it needs DATABASE_URL. ```text graft mcp [--branch <id>] [--elicit-approvals] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--elicit-approvals` | Ask this terminal's operator to decide a destructive call, instead of failing with an id for graft approve. | Needs `DATABASE_URL` on the Postgres engine; none on the static engine. ### `graft serve` Run the headless Graft runtime over HTTP: POST /api/fn/\<name>, POST /api/mcp, GET /api/content/v1/*, GET /healthz. This is what a self-hosted container runs. ```text graft serve [--branch <id>] [--port <n>] [--host <host>] [--studio] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--port <n>` | Port to listen on (default: 3903). Same as `PORT`. | | `--host <host>` | Host to listen on (default: 127.0.0.1, this computer only). Same as `HOST`. | | `--studio` | Serve Studio at /studio too. Same as `GRAFT_STUDIO`. | Needs `DATABASE_URL`. Runs until you stop it. ### `graft studio` Run Studio: edit content, approve or deny actions, and read the OpenAPI document. Without a database, saves go to your files. ```text graft studio [--branch <id>] [--port <n>] [--host <host>] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--port <n>` | Port to listen on (default: 4983). Same as `GRAFT_STUDIO_PORT`. | | `--host <host>` | Host to listen on (default: 127.0.0.1, this computer only). Same as `HOST`. | Uses `DATABASE_URL` when it is set, and works without it. Runs until you stop it. ### `graft studio invite` Print a sign-in link for someone without a GitHub account. It is signed with GRAFT_STUDIO_SECRET and expires after --days. To give someone an account instead, invite them from Studio's Settings. ```text graft studio invite <email> [--role <role>] [--name <name>] [--days <n>] [--url <url>] [--json] ``` | Flag | What it does | | --- | --- | | `--role <role>` | viewer, contributor, editor (default) or admin. | | `--name <name>` | The name commits are attributed to. | | `--days <n>` | How long the link works, 1 to 30 (default: 7). | | `--url <url>` | Where Studio is served. Same as `GRAFT_STUDIO_URL`. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ### `graft studio setup-code` Print the code a hosted Studio asks for before it creates the owner account. It comes from GRAFT_STUDIO_SECRET, so run it with the site's settings. ```text graft studio setup-code ``` Needs no database. ## Keep content in the database ### `graft content import` For a project with content = \{ source: "database" }: load the files under the content directory into the database. Every file is checked first. Only documents with nothing published come in: a published document is never overwritten, and a missing file deletes nothing. To change published content, use Studio or graft push. ```text graft content import [--branch <id>] [--dry-run] [--json] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--dry-run` | Show what would change without changing it. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft pull` For a project with content = \{ source: "database" }: write what is published into the files under the content directory. A file you changed since your last pull is kept for graft push. One changed both here and in the database is left as it is and listed, unless --force takes the published version. ```text graft pull [doc...] [--branch <id>] [--only <doc>]... [--force] [--json] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--only <doc>` | Only this document, like posts/launch (same as naming it as an argument). Repeat it for more than one. | | `--force` | Settle a conflict: pull takes the published version, push publishes yours. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ```sh graft pull posts/launch ``` ### `graft push` Publish the files you changed since your last graft pull, checked as graft compile checks them. Refused, with nothing written, if someone published one of those documents since your pull: pull first. --force publishes over it. ```text graft push [doc...] [--branch <id>] [--only <doc>]... [--dry-run] [--force] [--json] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--only <doc>` | Only this document, like posts/launch (same as naming it as an argument). Repeat it for more than one. | | `--dry-run` | Show what would change without changing it. | | `--force` | Settle a conflict: pull takes the published version, push publishes yours. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft mirror` Commit everything published since the last run to the GitHub repository in GRAFT_GITHUB_REPO, as one commit. Run it on a schedule, or let graft serve do it when graft.config.ts sets content.mirror. ```text graft mirror [--branch <id>] [--json] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ## Manage the database ### `graft db migrate` Apply the Postgres schema migrations that ship with @usegraft/db. This is the first command a Postgres-engine project runs. ```text graft db migrate [--dry-run] [--json] ``` | Flag | What it does | | --- | --- | | `--dry-run` | List pending schema migrations without applying them. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft db claim` Keep the free database `graft create --database new` made. Prints the link to claim it (a free Neon account), waits until you have, then asks for the new connection string and saves it to .env. Claiming changes the password, so the old DATABASE_URL stops working. ```text graft db claim [--url <postgres-url>] [--no-input] ``` | Flag | What it does | | --- | --- | | `--url <postgres-url>` | The claimed database's connection string, instead of the question. | | `--no-input` | Never stop to ask a question. Same as `GRAFT_PROMPT_DISABLED`. | Needs no database. May ask questions on a terminal; `--no-input` never does. ### `graft migrate` Show pending content and data migrations. --apply runs them and records each in the ledger. A concurrent --apply on the same branch waits for this one and then skips what it applied. ```text graft migrate [--branch <id>] [--apply] [--json] ``` | Flag | What it does | | --- | --- | | `-b, --branch <id>` | Content branch to work on (default: main). | | `--apply` | Make the change (without it, only report what would change). | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft harden` Grant an existing Postgres role the runtime privilege set: it can request and consume approvals but never decide them. ```text graft harden <role> [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ## Approve actions ### `graft approvals` List actions waiting for a person to approve them. ```text graft approvals [--json] [--remote] [--local] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--remote` | Work on the hosted site in GRAFT_URL, or the one graft login saved. | | `--local` | Work on the project in this folder, even when GRAFT_URL is set. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft approve` Approve an action; the caller then repeats its call with the id. ```text graft approve <approval-id> [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft deny` Deny an action. ```text graft deny <approval-id> [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ## Work with preview branches ### `graft branch` List branches. ```text graft branch [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft branch create` Create a preview branch. ```text graft branch create <name> [--from <parent>] [--backend overlay|neon] [--json] ``` | Flag | What it does | | --- | --- | | `--from <parent>` | Branch to fork from (default: main). | | `--backend overlay\|neon` | overlay (default) shares the parent's rows; neon forks a physical Neon branch. | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft branch drop` Delete a branch: purge an overlay's rows, or delete a Neon fork. ```text graft branch drop <name> [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ### `graft merge` Merge a branch: replay its migrations, move its data, recompile. ```text graft merge <branch> [--into <target>] [--apply] [--json] ``` | Flag | What it does | | --- | --- | | `--into <target>` | Branch to merge into (default: main). | | `--apply` | Make the change (without it, only report what would change). | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs `DATABASE_URL`. Prints its answer as JSON with `--json`. ## Keep CI cheap ### `graft ci changes` Print content-only=true when every changed file is content, and write it to $GITHUB_OUTPUT for later steps to read. ```text graft ci changes [--base <ref>] [--json] ``` | Flag | What it does | | --- | --- | | `--base <ref>` | Git ref to compare against (default: the pull request's base, the host's previous deploy, or the commit a push started from). | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ### `graft ci skip-build` Exit 0 when the deploy can be skipped: only content changed, and the site already serves it from the database. Never skips on the previous commit alone. ```text graft ci skip-build [--base <ref>] [--json] ``` | Flag | What it does | | --- | --- | | `--base <ref>` | Git ref to compare against (default: the pull request's base, the host's previous deploy, or the commit a push started from). | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. | Exit code | What it means | | --- | --- | | `0` | Skip the build. | | `1` | Build. | ### `graft ci init` Write a workflow that runs graft check on content changes. ```text graft ci init [--overwrite] ``` | Flag | What it does | | --- | --- | | `--overwrite` | Replace the workflow file if it exists. | Needs no database. ## Work with a hosted site ### `graft login` Check a token against a hosted Graft site and save it, so commands that take --remote and graft api can use it. Make the token in Studio under Developer, Agents. It is read from stdin, or asked for without echoing it, never taken as an argument. No token can approve or deny an action. ```text graft login <url> [--with-token] [--json] [--no-input] ``` | Flag | What it does | | --- | --- | | `--with-token` | Read the token from stdin (the default when stdin is not a terminal). | | `--json` | Print one JSON object on stdout; messages go to stderr. | | `--no-input` | Never stop to ask a question. Same as `GRAFT_PROMPT_DISABLED`. | Needs no database. Prints its answer as JSON with `--json`. May ask questions on a terminal; `--no-input` never does. ```sh printf %s "$TOKEN" | graft login https://cms.example.com --with-token ``` ### `graft logout` Remove a site's saved token (default: the default site). The token itself keeps working until someone revokes it in Studio under Developer, Agents. ```text graft logout [url] [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ### `graft auth status` List every site the CLI can reach, from graft login and GRAFT_URL, and check each token against the site. Tokens are shown by their first characters only. ```text graft auth status [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. | Exit code | What it means | | --- | --- | | `0` | At least one token works, or --json was given. | | `4` | No token works, or there is none. | ### `graft auth token` Print a site's saved token, for piping into another tool. ```text graft auth token [url] ``` Needs no database. ### `graft api` Call a route on the site in GRAFT_URL, or the one graft login saved. A path is under /api/studio/v1 unless it starts with /api/fn or /api/content/v1. Fields make the request a POST with a JSON body; with -X GET or --paginate they become the query string. Prints the answer; with no path, lists the routes. ```text graft api [path] [--method <method>] [--raw-field <key=value>]... [--field <key=value>]... [--header <name: value>]... [--input <file|->] [--include] [--paginate] [--yes] ``` | Flag | What it does | | --- | --- | | `-X, --method <method>` | The HTTP method (default: GET, or POST when there are fields or --input, unless --paginate). | | `-f, --raw-field <key=value>` | A field whose value is sent as a string. Repeat it for more than one. | | `-F, --field <key=value>` | A field whose value is typed: true, false, null and numbers as JSON, @file and @- as the file's text. Repeat it for more than one. | | `-H, --header <name: value>` | An extra request header. Repeat it for more than one. | | `--input <file\|->` | Send this file as the body; - reads stdin. | | `-i, --include` | Print the status line and headers before the body. | | `--paginate` | GET every page, following nextCursor, and print each one. | | `-y, --yes` | Go ahead with -X DELETE. | Needs no database. ```sh graft api workspace graft api entries -X GET -f collection=posts -F limit=20 graft api entries -f collection=posts --paginate graft api -X PUT entry --input entry.json ``` ## Learn the CLI ### `graft help` Show help for a command or a topic. ```text graft help [command-or-topic...] ``` Needs no database. ```sh graft help branch create graft help exit-codes ``` ### `graft commands` List every command; --json describes each one and its flags. ```text graft commands [--json] ``` | Flag | What it does | | --- | --- | | `--json` | Print one JSON object on stdout; messages go to stderr. | Needs no database. Prints its answer as JSON with `--json`. ## Exit codes Every command exits with one of these, unless its entry above lists its own. | Exit code | What it means | | --- | --- | | `0` | It worked. | | `1` | It failed; the error says why and what to do. | | `2` | The command was called wrong: an unknown command or flag, or a missing argument. Nothing ran. | | `3` | A person has to approve the action first. Run `graft approve <id>`, then repeat it. | | `4` | The credential is missing, wrong, or lacks the access this needs. | ## Environment variables These change how the CLI behaves. Your project's own settings are on the [Configuration](/docs/configuration) page. | Variable | What it does | | --- | --- | | `DATABASE_URL` | The Postgres database. Read from the environment, or from `.env` in this folder or any folder above it. | | `GRAFT_DEBUG` | Set to 1 to print the stack trace of an unexpected error. | | `GRAFT_URL` | A hosted site for commands that take --remote; when set, they use it without the flag. Read from the environment only, never from `.env`. | | `GRAFT_TOKEN` | The token for GRAFT_URL, instead of the one `graft login` saved. | | `XDG_CONFIG_HOME` | Where `graft login` keeps tokens, under `graft/hosts.json` (default: `~/.config`). | | `GRAFT_PROMPT_DISABLED` | Set to 1 to never ask a question, as `--no-input` does. | | `GRAFT_NO_UPDATE_CHECK` | Set to 1 to never check npm for a newer Graft. Also read from `.env`. | | `CI` | When set, the update check is off. | | `NO_COLOR` | When set, never print color. | | `FORCE_COLOR` | When set, print color even when the output is not a terminal. | | `CLICOLOR` | Set to 0 to turn color off on a terminal. | | `CLICOLOR_FORCE` | Same as `FORCE_COLOR`. | | `TERM` | `dumb` turns color off. | | `XDG_CACHE_HOME` | Where the update check caches its answer, under `graft/` (default: `~/.cache`). | | `npm_config_registry` | The npm registry the update check asks. | ## Which commands need a database Graft reads `.env` from the current folder or any folder above it. - **Always need `DATABASE_URL`:** `graft compilations`, `graft drafts`, `graft drafts publish`, `graft drafts discard`, `graft serve`, `graft content import`, `graft pull`, `graft push`, `graft mirror`, `graft db migrate`, `graft migrate`, `graft harden`, `graft approvals`, `graft approve`, `graft deny`, `graft branch`, `graft branch create`, `graft branch drop`, `graft merge`. - **Need it on the Postgres engine only:** `graft compile`, `graft dev`, `graft content`, `graft content list`, `graft content get`, `graft content search`, `graft content write`, `graft content set`, `graft content delete`, `graft schema`, `graft fn`, `graft fn describe`, `graft fn run`, `graft mcp tools`, `graft mcp call`, `graft mcp`. - **Use it when it is set:** `graft doctor`, `graft studio`. - **Never need it:** `graft create`, `graft init`, `graft add`, `graft check`, `graft asset put`, `graft explain`, `graft studio invite`, `graft studio setup-code`, `graft db claim`, `graft ci changes`, `graft ci skip-build`, `graft ci init`, `graft login`, `graft logout`, `graft auth status`, `graft auth token`, `graft api`, `graft help`, `graft commands`. --- <!-- source: https://graft.page/docs/cli --> <!-- section: Reference --> # Script the CLI > Read graft's output from a script or an agent: JSON on stdout, errors as JSON on stderr, and an exit code that says what kind of failure it was. *Run graft from a script or an agent and read exactly what happened.* Every graft command works the same way when a program runs it instead of a person. The data goes to stdout, messages go to stderr, and the exit code says whether it worked and, if not, what kind of failure it was. This page covers those rules. The [CLI reference](/docs/cli-reference) lists every command and flag. ## Find a command and its flags `graft --help` lists the commands by task. `graft <command> --help` shows one command's usage, flags and examples, even when the rest of the line is incomplete. `graft help <command>` prints the same thing. ```sh graft help branch create graft help exit-codes ``` The help topics are `exit-codes`, `json` and `environment`. An agent can learn the whole CLI in one call. `graft commands --json` prints every command with its usage, arguments, flags, whether it takes `--json`, and whether it needs a database. ```sh graft commands --json ``` A command only takes the flags it declares. A misspelt or misplaced flag stops the command before it does anything, names the closest match, and exits 2: ```text graft: unknown option "--jsno" for graft compile. Did you mean `--json`? Run `graft compile --help`. ``` ## Get JSON instead of text Every command that prints data takes `--json`. It then prints one JSON object on stdout and nothing else. Progress and messages still print, on stderr, so a script can save stdout and a person can still read the log. The object is indented on a terminal and on one line when piped or redirected. ```sh graft check --json ``` ```json {"documents":1,"problems":[]} ``` Each command's entry in the [CLI reference](/docs/cli-reference) says whether it takes `--json`. Commands that run until you stop them (`graft dev`, `graft serve`, `graft studio`, `graft mcp`) do not. ## Read and write content from the terminal The `graft content` commands run the same tools an agent calls over the Model Context Protocol (MCP), the open standard that lets agents use tools. They run them in the CLI's own process, so they check the schema, compile, and file approvals exactly as the agent's call would, and need no database on the static engine. Print a document as the file it is, change it, and write it back: ```sh graft content get pages/home > home.mdx graft content write pages/home --file home.mdx ``` ```text Wrote pages/home.mdx on branch "main": +0 added, ~1 changed, -0 removed. ``` `--file -` reads the document from stdin. To write frontmatter alone, pass it as JSON with `--data '{"title":"About"}'`, and the body with `--body-file`. Find documents, and change one field on several at once: ```sh graft content search pricing --collection pages graft content set pages home about --field tagline --value '"Updated"' ``` `graft content set` writes each document the way `graft content write` does. One that fails is listed with its error and fix, the others are still saved, and the command exits 1. Deleting needs a person. The first run files an approval and exits 3, and its `fix` is the exact command to repeat: ```text error DESTRUCTIVE_OP_REQUIRES_APPROVAL: "delete_content" is destructive and always requires human approval. An approval request has been filed. fix: Ask a person to run `graft approve f9cb72bb-4b39-48e7-913b-9b9979da310e`, then run `graft content delete pages/about --approval f9cb72bb-4b39-48e7-913b-9b9979da310e`. The approval works once, for this exact call. ``` Approvals live in Postgres, so on the static engine `graft content delete` answers `NEEDS_DATABASE`: delete the file and compile instead. `graft schema` prints each collection's fields, `graft fn run` runs a typed function, `graft drafts` handles drafts where content is kept in the database, and `graft explain <code>` explains any error code. `graft mcp call` runs any other tool by name. The [MCP tools](/docs/mcp-tools#call-a-tool-from-the-terminal) page maps each tool to its command. ## Work with a hosted site The same commands work on a site you host with `graft serve --studio`, through its `/api/mcp` endpoint. Make a token in Studio under **Developer, Agents**, then save it once: ```sh printf %s "$TOKEN" | graft login https://cms.example.com --with-token ``` `graft login` checks the token with the site, prints who it acts as and what it may do, and saves it in `~/.config/graft/hosts.json` (under `XDG_CONFIG_HOME` when that is set), readable by you alone. Without `--with-token`, on a terminal, it asks for the token without echoing it. A token is never an argument, where it would land in your shell history. Then add `--remote` to a content command: ```sh graft content list --remote graft content write posts/launch --file launch.mdx --remote ``` Each prints the site it is using on stderr. In CI, set `GRAFT_URL` and `GRAFT_TOKEN` in the environment instead: commands that take `--remote` then use the site without the flag, and `--local` brings them back to the folder's project. `GRAFT_URL` is never read from `.env`, so a file in some folder cannot point a command at a production site. `graft auth status` checks every saved token against its site, and `graft logout` forgets one on this computer. A token keeps working until someone revokes it in Studio. No token can approve an action. A delete over `--remote` exits 3, and a person approves it in the site's Studio before you repeat it with `--remote --approval <id>`. ### Call the Studio API `graft api` calls any [Studio API](/docs/studio-api) route on the site with the saved token, so you never paste the token into a curl command: ```sh graft api workspace graft api entries -f collection=posts --paginate graft api -X PUT entry --input entry.json ``` A path is under `/api/studio/v1` unless it starts with `/api/fn` or `/api/content/v1`. `-f key=value` sends a string and `-F key=value` sends `true`, `false`, `null` or a number as JSON. Fields make the request a POST with a JSON body; with `-X GET` or `--paginate` they become the query string. `--paginate` follows `nextCursor` and prints every page. `graft api` with no path lists the routes. The answer prints on stdout. A refused request also prints its error and fix on stderr and exits 1, or 4 when the token lacks the access. `-X DELETE` needs `--yes`. `graft api` never follows a redirect, and never sends the token to another site. ## Read a failure When a command run with `--json` fails, stderr gets one line of JSON with the same fields every Graft error has: the `error` code, a `message`, the `fix` that names the next step, and `details` when there are any. Stdout stays empty. ```json {"error":"USAGE_INVALID","message":"unknown option \"--jsno\" for graft compile.","fix":"Did you mean `--json`? Run `graft compile --help`.","details":{"command":"compile","flag":"--jsno","suggestion":"--json"}} ``` Without `--json`, the same error prints for a person: ```text error CONFIG_NOT_FOUND: No graft.config.{ts,js} found in /tmp or any parent directory. fix: cd into a Graft project, or scaffold one here with `graft init`. ``` The [error reference](/docs/errors) explains every code. ## Act on the exit code | Exit code | What it means | What to do | | --- | --- | --- | | `0` | It worked. | Nothing. | | `1` | It failed. | Read the error and its `fix`. | | `2` | The command was called wrong. Nothing ran. | Fix the command; retrying it as it is fails the same way. | | `3` | A person has to approve the action first. | A person runs `graft approve <id>`, then you repeat the action. | | `4` | The credential is missing, wrong, or lacks the access this needs. | Fix the credential before retrying. | `graft doctor`, `graft check` and `graft ci skip-build` use their own codes, listed in their reference entries. `graft ci skip-build`, for example, exits 0 when the deploy can be skipped and 1 when it has to build. ## Print what a compile changed `graft compile --json` prints the branch, the commit and what changed. The summary line goes to stderr. It works on both engines. ```json { "branch": "main", "gitSha": "b8eb719a468be404bae9adca4fbea4d90c642411", "changes": { "added": ["posts/launch"], "changed": ["pages/home"], "removed": [], "unchanged": 42 } } ``` `added`, `changed`, and `removed` are lists of `collection/slug` ids, and `unchanged` is a count. `gitSha` is `null` when the content is not in a git repository. On the static engine, a content change ships with a new deploy, so there is nothing to refresh. On the Postgres engine, writes from agents, Studio, `graft merge` and `graft migrate` call your app's revalidate route when `GRAFT_REVALIDATE_URL` and `GRAFT_WEBHOOK_SECRET` are both set. With the URL alone, these commands refuse to start. `graft compile` does not, because a deploy compiles before the new version is live. Send its output to the route yourself so the app refreshes only the pages that changed. A shell does not read `.env`, so export `GRAFT_WEBHOOK_SECRET` in the script's environment first: ```sh graft compile --json > changes.json || exit 1 curl --fail -X POST https://example.com/api/revalidate \ -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" \ -H "content-type: application/json" \ --data-binary @changes.json ``` See [Refresh pages when content changes](/docs/next#refresh-pages-when-content-changes) for the route. ## Stop the CLI from asking questions Only `graft create` and `graft db claim` ask questions, and only on a terminal. Pass `--no-input`, or set `GRAFT_PROMPT_DISABLED=1` for every command at once, and they never ask. `graft create` then takes the default for each question a flag did not answer. `graft db claim` says to run it again with `--url`. ## Turn color off or on graft prints color only to a terminal. `NO_COLOR` turns it off everywhere. `TERM=dumb` and `CLICOLOR=0` turn it off too. `FORCE_COLOR` or `CLICOLOR_FORCE` turn it on when the output is not a terminal. Set `GRAFT_DEBUG=1` to print the stack trace of an unexpected error. ## Update notice When a newer Graft is on npm, `graft` prints a notice on stderr after the command finishes. `graft dev`, `graft serve` and `graft studio` print it while they run. It names the new version, the version you have, and the [upgrade guide](/docs/upgrading). On a beta, the notice is yellow, because beta releases often include security fixes. On a stable release it is cyan. The CLI asks the registry at most once a day and caches the answer in `~/.cache/graft/update-check.json` (or under `XDG_CACHE_HOME`). It uses the registry in `npm_config_registry` when you set one. A slow or unreachable registry never fails or delays a command by more than 1.5 seconds. The check is off when `GRAFT_NO_UPDATE_CHECK=1` or `CI` is set, under `--json`, and for `graft mcp`, `graft help`, `graft commands`, `--help` and `--version`. `GRAFT_NO_UPDATE_CHECK=1` works in the environment or in the project's `.env`. ## Next steps - [CLI reference](/docs/cli-reference): every command and flag. - [Error reference](/docs/errors): what each error code means and how to recover. - [Connecting agents](/docs/connecting-agents): give an agent the same operations over MCP. --- <!-- source: https://graft.page/docs/sdk-reference --> <!-- section: Reference --> # SDK reference > Every Graft SDK package, what each one adds, how to mount Graft routes, cache tags, and where the frameworks differ. *Which package to use, and what differs between frameworks.* Use this page to look up a package, compare frameworks, mount Graft's routes, or wire up cache tags. To learn the three reads themselves, start with [Reading content](/docs/reading-content). Your framework's guide covers installing and creating the client. Every package reads through `@usegraft/sdk-core`. The framework packages wrap it, adjust it to how each framework works, and rename the reads (`getDocument` becomes `getContent`, and so on). The same `graft.config.ts` types all of them. | Package | For | Notes | | --- | --- | --- | | `@usegraft/sdk-core` | any JS | `getDocument` / `listDocuments` / `searchDocuments`; branch-aware; cache-tag helpers. Postgres lives behind `@usegraft/sdk-core/db` | | `@usegraft/sdk-next` | Next (RSC) | `React.cache` memo; `MdxBody`; `revalidateContent` / `updateContent` | | `@usegraft/sdk-astro` | Astro | No request memo; `graftRoute` for endpoint mounts | | `@usegraft/sdk-sveltekit` | SvelteKit | `graftRoute` for `+server.ts`; CDN surrogate keys for tags | | `@usegraft/sdk-tanstack-start` | TanStack Start | `graftRoute` for server routes; build the handle in a server-only module | | `@usegraft/sdk-react-router` | React Router v7 | Framework mode; `graftRoute` returns both `action` and `loader` | | `@usegraft/sdk-react` | the browser | Reads over HTTP; `createGraftHooks`; no `db` option, by design | Every adapter is typed **structurally**, so none of them takes a dependency on the framework it adapts and there is no peer version to keep in step. ## The read API ```ts const graft = createGraft({ db, collections, branch: "main" }); await graft.getContent("pages", "home"); await graft.listContent("docs"); await graft.searchContent("docs", "branching"); ``` Unknown collection names are compile-time errors when you pass your `collections` map. Reads re-validate; a poisoned index row throws. Every framework package accepts `db` directly, as above. `createClient` in `@usegraft/sdk-core` does not: it takes an `index`. For Postgres without a framework, use `createDbClient` from `@usegraft/sdk-core/db`. Keeping the database code on its own entry point means a browser install never pulls in a Postgres driver. Pass `index` instead of `db` to read a static SQLite artifact or a remote `graft serve` through `createContentApiReader`. The SDK surface does not change. See [Reading content](/docs/reading-content). `@usegraft/sdk-react` is the exception, and deliberately so: it takes an `endpoint`, never a `db`, because a Postgres handle in a browser bundle is a database URL in a browser bundle. It also refuses `branch` with a `CONFIG_INVALID`, since a `graft serve` endpoint pins its own branch and ignores the query param. Point at the preview's own endpoint instead. Its hooks come from `createGraftHooks(graft)` rather than being importable directly, which is what keeps them typed to your collections. ```ts const graft = createGraft({ endpoint, collections }); const { useContent } = createGraftHooks(graft); const { data, error, loading, refresh } = useContent("docs", slug); ``` They are a binding, not a cache: no deduplication, no retries, no stale-while-revalidate. `graft.getContent` is a plain async function, so compose it with TanStack Query or SWR if you want those. ## Mount Graft's routes in your app Handlers are Web-standard. Mounts are thin: ```ts // Astro: src/pages/api/fn/[name].ts export const POST = graftRoute(functionsHandler); // SvelteKit: src/routes/api/fn/[name]/+server.ts export const POST = graftRoute(functionsHandler); // TanStack Start: src/routes/api/fn/$name.ts export const ServerRoute = createServerFileRoute("/api/fn/$name").methods({ POST: graftRoute(functionsHandler), GET: graftRoute(functionsHandler), // answers 405 }); // React Router v7: app/routes/api.fn.$name.ts export const action = graftRoute(functionsHandler); // POST export const loader = graftRoute(functionsHandler); // GET, answers 405 // Next: app/api/fn/[name]/route.ts export const POST = functionsHandler; ``` MCP mounts the same way with `createGraftMcpHandler`. React Router is the one that looks wrong and is not. It splits a route by method into two exports rather than naming the method, so the handler is mounted twice. Exporting `loader` is what makes a GET answer with Graft's 405 and its `Allow` header and `fix`, instead of React Router's own "no loader" error, which teaches the caller nothing. ## Cache tags | Helper | Use | | --- | --- | | `tagsFor(branch, collection, slug?)` | The tags a read depends on | | `tagsForChanges(branch, changes)` | The tags to refresh after content changes | In the Next.js App Router, tag cached reads and refresh them with `revalidateContent`. On Next.js 16, tag reads with `'use cache'` and `cacheTag`. On Next.js 15, wrap them in `unstable_cache` with `tags` ([how](/docs/next#refresh-pages-when-content-changes)). Every other framework sends the tags in a CDN header and purges them at the CDN. Unchanged documents are never refreshed, and a change on a preview branch never refreshes `main`. ## Where the frameworks differ - **`MdxBody` is Next-only.** It evaluates MDX in a Server Component, which no other adapter here has. The Next.js Pages Router compiles with `prepareMdx` in `getStaticProps` and renders with `MdxContent` instead. Astro and SvelteKit author bodies for `@astrojs/mdx` or mdsvex; TanStack Start and React Router render authored source with their own pipeline. - **No `React.cache` equivalent outside Next.** Call sites own memoization. TanStack Start route loaders are isomorphic, running on the server for first paint and in the browser on client navigation, so a handle holding a database connection belongs in a server function or server route and never in a loader. - **No tag-based data cache outside Next.** Everything else maps the `@usegraft/sdk-core` tag contract onto HTTP: stamp `tagsFor(...)` into a CDN header and purge `tagsForChanges(...)` at the CDN when content changes. - **`sdk-react` has no cache and no `db` option,** by design. See above. - **Next.js apps that mount the MCP route need `withGraft(nextConfig)`.** It keeps `@usegraft/registry` out of the server bundle, because that package reads its files from disk at runtime. --- <!-- source: https://graft.page/docs/configuration --> <!-- section: Reference --> # Configuration > Environment variables for compile, serve, auth, MCP, and assets. *Every environment variable, in one place. A static project needs none of them.* Look up an environment variable or a `graft.config.ts` export here. Graft reads `.env` from the current folder or any folder above it. ## Required: none A static project needs no environment at all. `graft init` scaffolds the static index, `graft compile` writes `.graft/index.db`, and your app reads it embedded. Everything below is for a capability you opt into. ## Postgres Required once `graft.config.ts` sets `export const index = "postgres"`, which is what operational data, typed functions, and copy-on-write branches need. | Variable | Used by | Purpose | | --- | --- | --- | | `DATABASE_URL` | compile, dev, migrate, branch, merge, serve, app reads | Postgres connection | ## Identity and policy | Variable | Purpose | | --- | --- | | `GRAFT_DEV_TOKEN` | Static bearer for local / bootstrap actors | | `GRAFT_DEV_SCOPES` | Comma-separated scopes for the dev token | | `GRAFT_TRUSTED_ISSUERS` | Comma-separated OIDC issuer URLs (discovery). `graft serve` parses these into `issuers`; an embedded resolver must be passed `issuers` itself | | `GRAFT_TRUSTED_AUDIENCE` | Comma-separated token audiences `graft serve` accepts from every trusted issuer. Unset leaves the audience unchecked, with a startup warning | | `GRAFT_TRUSTED_ACTOR_KIND` | `agent` (default) or `human`: what `graft serve` records trusted-issuer callers as | | `GRAFT_MCP_ALLOW_ANONYMOUS` | `1` to serve anonymous MCP callers (local dev only; refused by default) | | `approvalPolicy` (graft.config.ts, not an env var) | `human` gates every mutation, `unattended` gates nothing | ## Serve | Variable | Purpose | | --- | --- | | `PORT` / `HOST` | Bind for `graft serve` (default loopback `:3903`) | | `GRAFT_HARDEN` | Container: create + harden the runtime role at boot, then serve under it. Default `1` in all-in-one, `0` in `GRAFT_MODE=serve` | | `GRAFT_RUNTIME_PASSWORD` | The hardened role's password; setting it also turns hardening on. Generated per boot when unset | | `GRAFT_MODE` | Container compose: `serve` against external db/storage | | `GRAFT_REVALIDATE_URL` | Your app's revalidate route. `graft serve`, `graft studio`, `graft mcp`, `graft merge` and `graft migrate` POST `{ branch, gitSha, changes }` to it after each write that changes content. https only, except on loopback (`localhost`, `127.0.0.1`, `[::1]`). See [Refresh pages when content changes](/docs/next#refresh-pages-when-content-changes) | | `GRAFT_WEBHOOK_SECRET` | Sent as `Authorization: Bearer …` to `GRAFT_REVALIDATE_URL`. Required when the URL is set. The app's route checks the same value | ## Studio `graft serve --studio` reads these. Set `GRAFT_GITHUB_REPO` to make saves land on GitHub instead of the content directory. See [Host Studio for your team](/docs/studio-hosting) for the full setup. | Variable | Purpose | | --- | --- | | `GRAFT_STUDIO` | `1` mounts Studio at `/studio` on `graft serve`, like `--studio` | | `GRAFT_STUDIO_PORT` | Port for `graft studio`. Default `4983` | | `GRAFT_STUDIO_SECRET` | Turns on sign-in. At least 32 random characters. Signs session cookies and invite links, so changing it signs everyone out | | `GRAFT_STUDIO_URL` | Public origin people reach Studio on. GitHub sign-in and `graft studio invite` need it | | `GRAFT_STUDIO_EDITORS` | Who may sign in with GitHub, Google or single sign-on without a Studio account or an invitation, as comma-separated logins or emails, each with an optional `=role` (default `editor`). Unset lets anyone with write access to the repository sign in with GitHub. Invite links work regardless | | `GRAFT_STUDIO_AUTO_JOIN` | Email domains whose people join on their own when they sign in with GitHub, Google or single sign-on, as comma-separated domains, each with an optional `=role` (default `contributor`; never `admin`). Free mail providers are refused. See [Host Studio for your team](/docs/studio-hosting#2-let-people-sign-in) | | `GRAFT_GITHUB_CLIENT_ID` / `GRAFT_GITHUB_CLIENT_SECRET` | A GitHub OAuth app, for "Sign in with GitHub". Set both or neither | | `GRAFT_GOOGLE_CLIENT_ID` / `GRAFT_GOOGLE_CLIENT_SECRET` | A Google OAuth client of type Web application, for "Continue with Google". Set both or neither | | `GRAFT_OIDC_ISSUER` / `GRAFT_OIDC_CLIENT_ID` / `GRAFT_OIDC_CLIENT_SECRET` | Another OpenID Connect provider, for single sign-on: Okta, Entra ID, your app's Better Auth and others. Set all three or none. See [Host Studio for your team](/docs/studio-hosting#2-let-people-sign-in) | | `GRAFT_OIDC_NAME` | The provider's name on the single sign-on button. Optional | | `GRAFT_GITHUB_REPO` | `owner/name`. Studio and MCP writes go to draft branches in this repository | | `GRAFT_GITHUB_TOKEN` | A fine-grained token with Contents and Pull requests read and write | | `GRAFT_GITHUB_APP_ID` / `GRAFT_GITHUB_APP_PRIVATE_KEY` | A GitHub App instead of a token. The key is PEM, literal `\n` accepted | | `GRAFT_GITHUB_APP_INSTALLATION_ID` | Optional. Found from the repository when unset | | `GRAFT_GITHUB_BRANCH` | The production branch publishing writes to. Default `main` | | `GRAFT_GITHUB_CONTENT_PATH` | The content directory's path in the repository. Default: its path relative to the project, or `content` when the directory is outside the project | | `GRAFT_GITHUB_API_URL` | GitHub Enterprise API base. Default `https://api.github.com` | | `GRAFT_STUDIO_PUBLISH` | `commit` (default) or `pull-request`. Use `pull-request` when the production branch is protected | | `GRAFT_DEPLOYED_SHA` | The commit this deployment was built from. Vercel, Render and Railway set their own, which Studio reads | ## Assets (S3-compatible) | Variable | Purpose | | --- | --- | | `S3_BUCKET` | Bucket name | | `S3_ENDPOINT` | API endpoint (R2, MinIO, Tigris, …) | | `S3_ACCESS_KEY` / `S3_SECRET_KEY` | Credentials | | `S3_REGION` | Optional. Defaults to `auto`, which suits R2 | | `S3_PUBLIC_URL` | Optional stable public base; else presigned GET | | `GRAFT_ENSURE_BUCKET` | Container: set `0` when the bucket already exists | ## Neon branching | Variable | Purpose | | --- | --- | | `NEON_API_KEY` | Create / drop physical CoW branches | | `GRAFT_NEON_PROJECT_ID` | Neon project id (required; keys may be project-scoped) | ## graft.config.ts exports The config is TypeScript you own, loaded at runtime. Only `collections` is required. | Export | Default | Purpose | | --- | --- | --- | | `collections` | required | Record of `defineCollection` results | | `functions` | `{}` | Record of `defineFunction` results; enables `list_functions` and `run_function` | | `contentDir` | `"content"` | Content root, relative to the config file | | `migrationsDir` | `"migrations"` | Migrations directory, relative to the config file | | `index` | `"postgres"` when the export is missing. `graft init` writes `"static"`. | `"static"` for the storage engine that needs no services | | `mdxTrust` | `"restricted"` | How much of MDX an authored body may be | | `content` | `{ source: "git" }` | Where your content is kept, which publishes need review, and whether to copy it into git | ### content Choose where your content is the real thing, the version everything else is built from. - **`"git"`** keeps it in the MDX files under `contentDir`. Studio saves files on your computer, or commits through GitHub when hosted. `graft compile` builds the index from the files. - **`"database"`** keeps it in Postgres. Studio and agents save drafts there, and publishing puts a change on the live site straight away, with no rebuild. `graft compile` builds the index from what is published, not from the files. A hosted Studio needs no GitHub setup at all. `graft.config.ts`: ```ts export const content = { source: "database" }; ``` The database source needs the Postgres engine, so it can't be combined with `index = "static"`. To move an existing project, set the export and run `graft content import`, which loads your files into the database. It brings in documents that have nothing published yet, and never overwrites one that does, so running it again is safe. Deleting a file does not delete its document: delete it in Studio instead. [Content in the database](/docs/content-in-the-database) covers pulling, pushing and keeping a copy in git. | Setting | Default | What it does | | --- | --- | --- | | `source` | `"git"` | `"git"` or `"database"` | | `review` | `{ deletes: true, overLines: 200 }` | For git-sourced projects hosted on GitHub: publishes that open a pull request even for an editor. `deletes` covers deletions and renames, `collections` lists collections whose every change is reviewed, and `overLines` catches large changes. `false` publishes everything directly | | `mirror` | none | For database-sourced projects: `{ mode: "batch" \| "each", via?: "commit" \| "pull-request", everyMinutes?: number }` copies what is published into the GitHub repository in `GRAFT_GITHUB_REPO` | `graft.config.ts`: ```ts export const content = { source: "git", review: { collections: ["legal"], overLines: 100 }, }; ``` In a database-sourced project these refuse with `CONTENT_SOURCE_MISMATCH`, because they work on files: reverting to a commit in Studio, `graft merge`, and content migrations. ### mdxTrust `"restricted"` refuses `{…}` expressions, `import`, `export` and spread attributes in authored bodies. `graft compile` checks every document and reports all offenders at once, so the refusal happens at build time rather than per-request. Set `"full"` only when every author of the repository has commit access. Rendering evaluates MDX as JavaScript on the server, so on a Studio hosted for writers, "can author a page" would otherwise mean "can execute code on the render host". `graft.config.ts`: ```ts export const mdxTrust = "full"; ``` `MdxBody` has its own `trust` prop with the same default, and the two have to agree: set `mdxTrust` here and pass `trust="full"` at the render site. Compile says so in the error when it refuses a document. ## Operator vs runtime Keep two URLs in production: - **Operator.** Compiles, migrates, merges, and approves from CI or your machine. - **Runtime.** Serves the deployment on `DATABASE_URL` after `graft harden`. On Vercel embedded deploys, run compile in the build step with the operator URL, then serve the app with the runtime URL. See [Deploy](/docs/deploy). --- <!-- source: https://graft.page/docs/content-api --> <!-- section: Reference --> # Content API > Serve your content over HTTP to frontends that cannot open the index themselves, with the same typed reads on the other end. *Serve your content over HTTP, for frontends that cannot read the index directly.* Usually your app reads content straight from the index: a file on the static engine, or the database on Postgres. Sometimes it cannot. A browser app has neither, and a frontend deployed separately from your content may not either. The Content API covers that case. A small HTTP handler serves your content, and a matching reader on the frontend fetches it. You give the reader to any Graft SDK as its `index`, and your code reads content exactly as before: `getContent`, `listContent`, `searchContent`, fully typed. It is read-only and serves one branch. This page shows how to serve it, read from it, and keep it private, then lists the endpoints in full. ## Serve it `graft serve` already serves it at `/api/content/v1`. To serve it from your own app, create the handler with the index reader you already have: `app/api/content/v1/[...path]/route.ts`: ```ts import { createContentApiHandler } from "@usegraft/content-api"; import { createDbIndexReader } from "@usegraft/db"; const handler = createContentApiHandler({ collections: ["docs", "pages"], // only these are served branch: "main", index: createDbIndexReader(db), }); export const GET = (request: Request) => handler(request); export const OPTIONS = GET; // browser preflight, when allowedOrigins is set ``` `collections` is the list of what is published. A collection not on it does not exist as far as the endpoint is concerned. `graft serve` publishes every collection in your config, so serve the handler yourself if you need a shorter list. ## Read from it On the frontend, create a reader for the endpoint and pass it to your SDK as the `index`: `src/lib/graft.ts`: ```ts import { createContentApiReader } from "@usegraft/content-api"; import { createGraft } from "@usegraft/sdk-astro"; import { collections } from "../../graft.config"; export const graft = createGraft({ index: createContentApiReader({ endpoint: "https://cms.example.com/api/content/v1", headers: { authorization: `Bearer ${process.env.GRAFT_CONTENT_TOKEN}` }, }), collections, }); ``` | Option | What it does | | --- | --- | | `endpoint` | The API's base URL | | `headers` | Headers sent with every request, such as `Authorization` | | `fetch` | Your own `fetch`, for tests, instrumentation, or runtimes without one | Every document is checked against your schema when it arrives, just as a local read is, so a document that does not match never reaches your code. Errors keep their meaning over HTTP. A Graft error on the server arrives as the same Graft error on the client, with its `code` and `fix`. Anything else, such as a proxy returning HTML, becomes `FUNCTION_EXECUTION_FAILED` with a message saying what went wrong. ## It does not authenticate requests The handler checks no identity. Every document in every published collection is readable by anyone who can reach the URL. That is deliberate: published content is usually public, and any login scheme the handler guessed at would be wrong for most sites. So the `collections` list is your security boundary. If some content must stay private, do not publish its collection here. If the whole endpoint must be private, put a proxy in front of it that checks a token, and send that token with the reader's `headers`. Do not rely on nobody finding the URL. The one protection built in is a [rate limit](/docs/rate-limits#the-content-api). `graft serve` sets 60 requests per 60 seconds. A handler you mount yourself has none unless you pass `rateLimit`. ## Read it from a browser A browser blocks reads from another origin unless the server allows them, and the handler allows none by default. If your browser app is served from a different origin than the API, list the origins that may read it: ```ts createContentApiHandler({ collections, branch: "main", index, allowedOrigins: ["https://app.example.com"], }); ``` On `graft serve`, set `GRAFT_CONTENT_ALLOWED_ORIGINS` to a comma-separated list, or to `*`. Allowing every origin gives nothing away here, since anyone can already read the endpoint from outside a browser. Error responses carry the same headers, so a failing browser request shows its `fix` instead of an opaque network error. ## Endpoints Two routes, both `GET`: | Route | Required | Optional | Returns | | --- | --- | --- | --- | | `/api/content/v1/documents` | `collection` | `slug`, `limit`, `offset` | `{ "rows": [...] }` | | `/api/content/v1/search` | `collection`, `query` | `limit` | `{ "hits": [{ row, rank, snippet }] }` | ```sh curl "https://cms.example.com/api/content/v1/documents?collection=docs&slug=tiers" curl "https://cms.example.com/api/content/v1/search?collection=docs&query=branching" ``` `limit` must be a positive whole number, and values above 500 are treated as 500. `offset` must be zero or more. Search takes one collection at a time. The `query` syntax is the same as [Search](/docs/search). ### Each row | Field | Type | | --- | --- | | `collection`, `slug` | string | | `data` | object, the checked frontmatter | | `body` | string, the Markdown or MDX body | | `sourcePath` | string, the file to edit | | `contentHash` | string | | `branchId` | string | | `deleted` | boolean | | `updatedAt` | string, ISO 8601 | | `search` | string or null | ### One endpoint serves one branch A `branch` query parameter is refused, so no request can switch a production endpoint onto preview content. For preview reads, serve a second endpoint for that branch. ### Errors | Status | Code | When | | --- | --- | --- | | 400 | `INPUT_VALIDATION_FAILED` | A bad `limit` or `offset`, an empty `slug`, a missing `collection` or `query`, or a `branch` parameter | | 404 | `COLLECTION_NOT_FOUND` | The collection is not published on this endpoint. The `fix` lists those that are. | | 404 | `ROUTE_NOT_FOUND` | Nothing is served at that path | | 405 | `METHOD_NOT_ALLOWED` | Anything other than `GET` | | 500 | `FUNCTION_EXECUTION_FAILED` | The endpoint could not read its index | ## Limitations - **Read-only.** Content is written through files, the CLI, or MCP. - **One branch per endpoint.** - **No operational data.** User data is only reachable through [typed functions](/docs/functions-and-access). - **No authentication.** See above. ## Next steps - [Reading content](/docs/reading-content) covers the reads this serves. - [Deploy](/docs/deploy) shows where the endpoint runs. - [React](/docs/react) reads it from the browser. --- <!-- source: https://graft.page/docs/studio-api --> <!-- section: Reference --> # 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. --- <!-- source: https://graft.page/docs/errors --> <!-- section: Reference --> # Error reference > Every Graft error code, what it means, and how to recover. 50 codes. Look up an error code you saw in a terminal, a log, or an agent's tool result. Every Graft error has three parts: a `code` from this list, a `message` saying what happened, and a `fix` naming the next step. The `fix` in your terminal is specific to your failure. The entry on this page explains the general cause, so you can tell whether the fix applies or something else is wrong. Agents can read the same entries without leaving their tools: the `explain_error` MCP tool returns them for any code. There are 50 codes, listed alphabetically. ## Codes ### ALREADY_INITIALIZED `graft init` was run in a directory that already has a graft.config. **Usually because** - Re-running init in an existing project - Pointing init at the wrong directory **How to recover** This is already a Graft project — evolve it by editing the existing graft.config.ts (add collections or fields there), or run `graft init <dir>` against an empty directory. ### APPROVAL_INVALID An approval id was supplied (the `approval` argument over MCP, the x-graft-approval header over raw HTTP), but it cannot authorize this call — details.reason says why (pending, denied, already_consumed, mismatch, or not_found). **Usually because** - Retrying before a human has decided (pending) - Reusing an approval — they are one-shot (already_consumed) - Changing the input or function between request and retry (mismatch) - A human refused the operation (denied) **How to recover** pending → wait for the human decision; denied → do not retry, ask the operator; already_consumed or not_found → call again without the approval id to file a fresh request; mismatch → retry with exactly the approved input. ### APPROVAL_SELF_DECISION The identity deciding an approval is the same identity that requested it. Separation of duties: a requester can never approve (or deny) their own destructive operation. **Usually because** - An agent (or a wrapper acting for it) running `graft approve` on an approval it filed itself - Passing the requester's identity as the decider (e.g. reusing the same dev-token id) **How to recover** A DIFFERENT operator must review it: they run `graft approve <id>` (or `graft deny <id>`) under their own identity. Do not retry as the requester and do not switch identities to impersonate a reviewer — the gate exists so a second party sees the exact function + input before it runs. ### APPROVAL_UNATTRIBUTED The approval was filed by a caller with no stable identity, so there is nobody the request can be held to and no meaningful separation of duties to enforce. It cannot be decided by anyone. **Usually because** - An unauthenticated caller reached a destructive function (an MCP mount or functions route serving anonymous actors) - A trusted issuer minted a token with no `sub` claim, so the actor authenticated but carried no id - A row left over from before approvals required an identified requester **How to recover** Fix the filing surface, not the row: require authentication where the approval was requested (an actor resolver, or a token that carries `sub`), then have the caller request the operation again so the approval records who wants it. A stale row can be resolved directly in the database. ### ASSET_EXISTS An asset upload targeted a key that already holds a binary. The store has no version history, so an overwrite would irreversibly replace it — uploads refuse unless overwrite is explicit. **Usually because** - Re-uploading with the same key instead of picking a new one - Two documents' assets colliding on a generic key like assets/hero.png **How to recover** Pick a distinct key (e.g. prefix it with the document: pages/pricing/hero.png) — that is almost always right. Only pass `overwrite: true` (put_asset) or `--overwrite` (graft asset put) when replacing the existing binary is the actual intent; every document referencing that key will show the new bytes. ### AUTHORITY_MISMATCH An operation used the wrong interface for a collection's authority: files/write_content on a db-authoritative collection, or record helpers on a file-authoritative one. **Usually because** - Authoring an MDX file for a collection whose rows live in Postgres - Calling insertRecord/listRecords on a collection whose documents are files **How to recover** Check the collection's authority with describe_schema. File-authoritative → author MDX (write_content / files + compile). Db-authoritative → go through its functions (POST /api/fn/\<name>); the rows are operational data Postgres owns. ### BRANCH_BACKEND_FAILED The branch backend's control plane (the Neon API for `neon` branches) rejected or failed an operation, so the branch's physical state may not match the registry. **Usually because** - An invalid or expired NEON_API_KEY, or one scoped to a different project - A wrong GRAFT_NEON_PROJECT_ID - Neon-side limits (max branches) or a transient API outage - The branch's compute endpoint never became reachable after create **How to recover** Check NEON_API_KEY and GRAFT_NEON_PROJECT_ID in the environment, then retry. If a create failed partway, the error says whether a Neon branch was left behind — delete it in the Neon console (or retry the drop) before recreating. Overlay branches never hit this: they need no external API. ### BRANCH_EXISTS A branch with that name is already registered. **Usually because** - Re-running a create for a branch that already exists - Two previews competing for the same name **How to recover** Use the existing branch, pick a different name, or drop the existing one first. Registering a branch is idempotent only if you check first — names are unique. ### BRANCH_INVALID A branch operation was rejected because the name or the topology change is not allowed. **Usually because** - A branch name that is not URL-safe (uppercase, spaces, punctuation) - Making a branch its own parent, or dropping main - Dropping a branch that still has child branches **How to recover** Use lowercase kebab names with optional "/" segments (e.g. "preview/checkout"). Fork previews from main; drop child branches before their parent; main is the root and cannot be dropped. ### BRANCH_NOT_FOUND An operation referenced a branch that is not registered in the topology. **Usually because** - A typo in the branch name - Forking from or dropping a branch that was never created - Expecting a branch to exist that a teammate has not created yet **How to recover** List the registered branches (graft branch) to see valid names. "main" is always seeded; create previews off it before forking from or merging them. ### COLLECTION_NOT_FOUND Content referenced a collection that is not registered in the schema. **Usually because** - A typo in the collection name - A document placed in a folder that has no matching defineCollection - The collection exists in code but was not passed to compile()/the MCP server **How to recover** Call list_collections to see what is registered. Either use one of those names, move the file into a registered collection folder, or add a defineCollection for it in graft.config.ts. ### COMMIT_FAILED git refused to record the commit. The working tree is untouched by the refusal. **Usually because** - No committer identity configured (user.name / user.email) - A pre-commit or commit-msg hook rejected the change - Nothing to commit — the selected paths match the last commit already - The repository is mid-merge or mid-rebase **How to recover** The error's `details.stderr` carries git's own words. For an unset identity, run `git config user.name "…"` and `git config user.email "…"`. Selected files may already be staged; `git status` shows the current state, and committing from a terminal always remains available. ### CONFIG_INVALID graft.config exists but could not be loaded, or it does not export a valid `collections` record. **Usually because** - A syntax or import error in graft.config.ts - Missing `export const collections = { … }` - A collections entry that was not created with defineCollection **How to recover** Fix graft.config.ts so it imports defineCollection/field from @usegraft/core and exports `collections` as a record of defineCollection results — the error message names exactly what failed to load or validate. ### CONFIG_NOT_FOUND No graft.config.\{ts,js} was found in the working directory or any parent. **Usually because** - The command was run outside a Graft project - The project has not been initialized yet **How to recover** cd into the project root (the directory holding graft.config.ts) and retry, or scaffold a new project with `graft init`. ### CONTENT_CONFLICT A write or a publish was refused because the document changed underneath it: someone (a person in Studio, an agent over MCP, a developer's push) saved a newer version after this one was read. Nothing was written. **Usually because** - Two people, or two browser tabs, editing the same document - An agent called write_content on a document a person had open in Studio - A publish where the production branch changed the same document since the draft began **How to recover** Read the document again (`details.path`) and reapply the change on the current version. For a publish, `details.conflicts` lists each document; publish again choosing, per document, to keep the draft version or take the published one. ### CONTENT_DIR_NOT_FOUND The configured content directory does not exist on disk. **Usually because** - The project has no content/ directory yet - compile() was pointed at the wrong path **How to recover** Create the directory (documents live at \<contentDir>/\<collection>/\<slug>.mdx) or correct the contentDir passed to compile()/the MCP server. write_content creates folders automatically. ### CONTENT_REFERENCE_NOT_FOUND A reference field names a document that does not exist. graft compile stops on it, and a database-sourced publish refuses it, including the deletion of a document that live documents still point at. **Usually because** - The referenced document was deleted, renamed or given a new slug - A slug typo in the reference - The reference and its target were published separately, target last **How to recover** Read details.references: each names the file, the field and the missing collection/slug. Create the missing document, or point the field at one that exists (list_content shows what exists). To delete a document others point at, change them in the same publish. graft check lists every broken reference at once. ### CONTENT_SOURCE_MISMATCH The operation works on one source of content, and this project uses the other. A database-sourced project keeps its content in Postgres, so operations that read or rewrite the files in git (reverting to a commit, merging a branch's files, running a content migration over the files) have nothing to act on. **Usually because** - graft.config.ts exports `content = { source: "database" }` and the call reached for files - A script written for a git-sourced project ran against a database-sourced one **How to recover** Use the database-sourced equivalent the error's `fix` names. To change a document, write it with write_content and publish it with publish_drafts. Do not edit the files under the content directory and expect the site to change: they are a checkout, not the source. ### CONTENT_TREE_READ_ONLY A write reached the content tree, but the filesystem refused it. Authored content is files, so writing requires a writable checkout — serverless platforms deploy a read-only filesystem. **Usually because** - Studio or an MCP write served from a serverless deployment (Vercel, Netlify, Cloudflare) - A container with the project mounted read-only - File permissions on the content directory **How to recover** Run the writing surface where the checkout is writable — local `graft studio` / `graft mcp`, or a self-hosted container with the project mounted read-write — and let the deployment serve reads only. Writes then arrive as git commits, which is the model: git is authoritative for authored content. ### DESTRUCTIVE_OP_REQUIRES_APPROVAL The operation is human-gated: a pending approval request was filed (details.approvalId) and the call will not run until a human approves it. Destructive functions are always gated; under the 'human' approval policy every mutation is. **Usually because** - Calling a function marked `destructive: true` (deletes or irreversibly overwrites data) - Calling any mutation on a deployment whose approvalPolicy is 'human' - Calling a destructive function over MCP at all — the 'unattended' policy that lifts this gate for a headless deployment is not part of the MCP surface, so there is no server setting that makes this tool call stop asking. That is deliberate, and not something to route around **How to recover** Ask a human operator to run `graft approve <approvalId>` (they can also `graft deny` it). Once approved, retry the EXACT same call carrying the approval id — over MCP pass it as the `approval` tool argument; over raw HTTP send the `x-graft-approval: <approvalId>` header. Approvals are one-shot and bound to the exact input — never work around the gate. If you are seeing this over MCP, the server did not ask your client directly: either the mount has not opted into elicited approvals (`approvalElicitation`, off by default and intended for a local server whose operator is present) or your client did not declare the elicitation capability. Both are ordinary — the gate is the same either way, only the way the human is reached differs. ### DOCUMENT_NOT_FOUND No document with the requested slug exists in that collection's files. **Usually because** - A slug typo - The document lives in a different collection - The document was deleted (git is authoritative — the files are the truth) **How to recover** Call list_content for the collection to see every slug that exists, then retry with a real one — or author the document with write_content. ### ENV_VAR_MISSING A required environment variable is not set. **Usually because** - The project has no .env file yet - The variable exists in a different environment (e.g. CI but not local) **How to recover** Add the variable named in `details.variable` to the project's .env (the CLI walks parent directories to find one) or export it in the environment, then retry. ### FRAMEWORK_VERSION_UNSUPPORTED An adapter API needs a newer version of the framework than the app has installed. The rest of the adapter still works. **Usually because** - Calling `updateContent` from @usegraft/sdk-next on Next.js 15. It needs `updateTag`, which Next.js added in 16 **How to recover** Use the fallback the error's `fix` names. For `updateContent` that is `revalidateContent(branch, changes)`, which works on Next.js 15 and 16. Or upgrade the framework. ### FUNCTION_EXECUTION_FAILED The function's handler threw an unexpected (non-Graft) error — a bug in the handler or its environment, not in the caller's input. **Usually because** - A runtime error inside the handler code - The database is unreachable or a required env var is missing on the server **How to recover** Retrying with the same input will fail again. Find the invocation in the server logs via `details.correlationId`, fix the handler code or the server environment, then retry. ### FUNCTION_NOT_FOUND The invoked function name is not registered in the functions runtime. **Usually because** - A typo in the function name (the last path segment of the POST URL) - The function exists in code but was not included in the exported `functions` record **How to recover** The error's `details.available` lists every registered function — call one of those, or add a defineFunction to graft.config.ts and include it in the exported `functions`. ### GIT_UNAVAILABLE An operation needed git and could not reach it: either the `git` binary is not on PATH, or the content directory is not inside a git work tree. **Usually because** - The project was scaffolded but `git init` was never run - Studio or the CLI running in a container image that ships no git binary - The content directory lives outside the repository (a mount, a symlink target) **How to recover** Run `git init` at the project root and make a first commit, or install git. Nothing else in Graft requires it — content still compiles and serves — but the change history, `graft compile`'s recorded SHA, and reverting all depend on it. ### INDEX_OWNERSHIP A projection would soft-delete every document in collections this project's schema doesn't know — the signature of two projects pointing at one DATABASE_URL. Nothing was written; the transaction rolled back. **Usually because** - DATABASE_URL points at another project's database (e.g. a shared repo-root .env) - A collection was renamed or deleted in graft.config.ts, so its old rows look foreign **How to recover** Each project needs its own database or branch: set DATABASE_URL in a .env next to graft.config.ts (it overrides parent .envs). If the schema really did drop or rename a collection, a human runs `graft compile --prune-unknown` once — the override is CLI-only by design. ### INPUT_VALIDATION_FAILED A function invocation's input does not satisfy the function's Zod input schema. **Usually because** - The request body is not a JSON object - A required input field is missing or has the wrong type **How to recover** Fix the fields listed in `details.issues` and retry. describe_schema shows each function's exact input fields; an empty body is treated as \{}. ### INTERNAL_ERROR The graft CLI hit an error it does not recognize. It is a bug in Graft or in code the command loaded, not a mistake in how the command was called. **Usually because** - A bug in Graft - graft.config.ts or a module it imports throwing an ordinary Error **How to recover** Run the command again with GRAFT_DEBUG=1 to print the stack trace. If it points into graft.config.ts or your own code, fix it there; otherwise report it with the trace. ### INVALID_SLUG A slug is not URL-safe kebab-case. **Usually because** - Uppercase letters, spaces, or punctuation in the slug or filename - Leading/trailing/double hyphens **How to recover** Use lowercase letters, digits, and single hyphens only (e.g. "getting-started"). Set a valid `slug` in frontmatter or rename the file. ### METHOD_NOT_ALLOWED The endpoint was called with an HTTP method it does not serve. **Usually because** - GETting a function endpoint (functions are RPC over POST) - A client following a redirect that downgraded the method **How to recover** Use the method named in the `Allow` response header — for Graft functions, POST a JSON object body to the same URL. ### MIGRATION_FAILED A content or data migration could not be applied; nothing was written (runs are all-or-nothing). **Usually because** - The transform's output does not satisfy the collection's current schema - The transform threw for some documents/rows - A file's frontmatter is not parseable YAML **How to recover** Read details.failures — each entry names the file/row and why it failed. Fix the transform (or the listed files) in migrations/\<id>.ts, then re-run `graft migrate --apply`. Dry-run first with `graft migrate` to see what would change. ### MIGRATION_REQUIRED The schema and the stored content/data have drifted; a migration must run first. **Usually because** - graft.config.ts changed shape while documents still use the old shape - A database schema change is pending **How to recover** Run the pending migration (content migrations update the files; DB migrations via @usegraft/db), then retry the operation. ### NEEDS_DATABASE This project runs on the static storage engine (index = "static" in graft.config), and the requested feature needs the Postgres engine: db-authoritative collections, typed functions, or database branching. **Usually because** - A db-authoritative collection or a defineFunction was added to a static-mode project - graft compile --branch \<name> was run in static mode (branches are git branches there) **How to recover** Either stay static (content-only: use git branches for previews) or upgrade: set DATABASE_URL in .env and switch graft.config to `export const index = "postgres"`, then re-run `graft compile`. ### NOT_IMPLEMENTED The capability is planned but not built yet. **Usually because** - Calling a placeholder API from a later phase **How to recover** Use the documented alternative (the error's `fix` names it if one exists), or accomplish the task by editing files directly — git is always a valid interface. ### RATE_LIMITED This caller has invoked the function more times than its per-window limit allows. Every attempt counts, including rejected ones. **Usually because** - A retry loop hammering a function after failures - Many calls from one actor (or one IP, for anonymous callers) in a short window **How to recover** Wait out the window (the Retry-After header says how long) before retrying, and fix whatever caused the burst — the limit is per caller per function, so backing off actually works. ### REGISTRY_FILE_EXISTS `graft add` would overwrite a file that already exists in the project, so it wrote nothing (adds are all-or-nothing). **Usually because** - The item (or one of its dependencies) was already added - A project file happens to share a target path with the item **How to recover** Inspect the listed file(s). If replacing them is intended, re-run with `--overwrite`; otherwise move/rename your file first. `graft add --dry-run <name>` previews every path an item would write. ### REGISTRY_ITEM_INVALID A registry item's manifest is malformed, or the item requires a different @usegraft/core version than the one installed. **Usually because** - registry.item.json does not match the manifest schema - The item's `graftVersion` range does not include the installed core version - A file the manifest lists is missing from the item directory **How to recover** The error names what failed (a manifest field or the version mismatch). For a version mismatch, move @usegraft/core to the range the item needs; a malformed manifest is a registry bug — fix the item or report it. ### REGISTRY_ITEM_NOT_FOUND `graft add` was asked for a registry item (a copy-in primitive) that does not exist. **Usually because** - A typo in the item name - Expecting a community/remote item — only the bundled core registry ships today - The item was renamed or removed **How to recover** The error's `details.available` lists every item you can add. Run `graft add <name>` with one of those, or build the primitive by hand as owned code under graft/. ### REMOTE_STORE_FAILED The content store that writes to a remote repository (GitHub) refused or failed a request. When GitHub refused (`details.status` is set), that request was not applied, but an operation made of several requests may have finished earlier steps: a publish can move the production branch and then be refused while tidying the draft. When a request failed before an answer arrived (a network error), GitHub may still have applied it. Either way, check the draft and the production branch before retrying. **Usually because** - The token or GitHub App lacks Contents or Pull requests write access to the repository - GRAFT_GITHUB_REPO or GRAFT_GITHUB_BRANCH names a repository or branch that does not exist, or the credential cannot see a private repository (GitHub answers 404 for both) - A branch protection rule forbids direct pushes to the production branch - GitHub rate limiting or an outage **How to recover** Read the error message and `details`. A GitHub refusal carries `details.status` and `details.message`, GitHub's own answer; a failure before GitHub answered (network, a malformed response) has the message only. A 401 or 403 is credentials or permissions; a 404 is the repository or branch name, or a private repository the token or GitHub App is not installed on; a protected branch needs `GRAFT_STUDIO_PUBLISH=pull-request`. Retry once the cause is fixed: drafts already saved stay on their branch. After a network failure, reload the entry or the list of changes first, since the request may have gone through. ### REMOTE_UNREACHABLE The graft CLI could not reach the hosted site it was pointed at, or the site answered with something other than Graft. **Usually because** - A typo in the URL given to graft login or in GRAFT_URL - The site is down, or this computer has no network - The site does not run graft serve, so it has no /api/mcp or /api/studio/v1 **How to recover** Check the address with `graft auth status`, open it in a browser, and log in again with `graft login <url>` if it changed. Studio is served by `graft serve --studio`. ### REVALIDATE_FAILED The content was written and the index is up to date, but telling the app to refresh its cache failed. The app may serve the old copy until its next refresh. **Usually because** - GRAFT_WEBHOOK_SECRET differs from the secret the app's revalidate route checks (the route answers 401) - GRAFT_REVALIDATE_URL is unreachable from the server, redirects, or points at the wrong path - An onContentChange listener passed to an in-app mount threw **How to recover** Do not retry the write: it succeeded. Fix the cause the error's `fix` names. Then POST the write's `{ branch, gitSha, changes }` to the revalidate route: an agent's result carries `changes`, and the server logs the whole body with the error. Do not recompile instead. The index already holds the change, so a later compile reports these documents as unchanged and refreshes none of them. ### ROUTE_NOT_FOUND The Graft server (`graft serve`) has nothing mounted at the requested path — the request reached the right process but the wrong URL. **Usually because** - A typo in the endpoint path (e.g. /api/fns instead of /api/fn/\<name>) - Expecting the frontend app's routes on the headless runtime — graft serve hosts functions, MCP, authored-content reads, and health **How to recover** Use POST /api/fn/\<name> for typed functions, POST /api/mcp for the MCP Streamable HTTP surface, GET /api/content/v1/documents or GET /api/content/v1/search for authored content, or GET /healthz for liveness. The error's details carry the path that missed. ### SCHEMA_VALIDATION_FAILED A document's frontmatter does not satisfy its collection's Zod schema. **Usually because** - A required field is missing from the frontmatter - A field has the wrong type (e.g. a string where a number is expected) - The schema changed in graft.config.ts and existing documents were not updated **How to recover** Call describe_schema to see the exact fields the collection expects, fix the frontmatter (the error's `details.issues` lists every violation), then retry the write or re-run compile. ### SLUG_NOT_UNIQUE Two documents in the same collection resolve to the same slug. **Usually because** - Two files share a filename-derived slug - A frontmatter `slug` duplicates another file's slug **How to recover** The error's details list both files. Give one of them a unique slug (frontmatter `slug:` wins over the filename) and re-run compile. ### STATIC_INDEX_NOT_FOUND A read tried to open the static index artifact (.graft/index.db by default), but the file does not exist — the project has not been compiled yet. **Usually because** - graft compile has never run in this checkout - The artifact path in graft.config's `index` setting does not match where compile wrote it - A deploy shipped the app without running graft compile in the build step **How to recover** Run `graft compile` (or add it before the framework build in the deploy's build command). The artifact must be deployed with the app. ### STATIC_INDEX_UNSUPPORTED Static index mode needs the node:sqlite built-in with FTS5, which this Node runtime does not provide (FTS5 ships in the bundled SQLite only from Node 22.16). **Usually because** - Node older than 22.16 running the CLI or the app server **How to recover** Upgrade Node to 22.16+ (24 LTS recommended), or switch the project to the Postgres index (DATABASE_URL + `export const index = "postgres"`). ### TOKEN_INVALID A bearer token was sent but could not be verified — different from sending no token (which makes the caller anonymous). **Usually because** - An expired or not-yet-valid JWT - A token issued by an issuer this deployment does not trust - A wrong audience claim, a bad signature, or a malformed token **How to recover** Mint a fresh token from a trusted issuer (details.reason states what failed). Do not strip the Authorization header to fall back to anonymous — fix the token instead. ### UNAUTHORIZED The caller's token does not permit this operation. **Usually because** - A missing or expired agent token - A token scoped to reads used for a write - An anonymous call to a mutation that is not marked `public: true` (the secure default) **How to recover** Obtain a token with the required scope from the project owner; do not retry with the same credentials. If the function is meant to accept anonymous callers, its definition needs `public: true`. ### USAGE_INVALID A graft command was called with an unknown command, flag or subcommand, a missing argument, or a flag without its value. Nothing ran. **Usually because** - A typo in a command or flag name - A flag that belongs to a different command - A required argument left out, such as the branch name for graft merge **How to recover** Read the `fix`, which names the closest match when there is one, then run `graft <command> --help` for the exact usage. `graft commands --json` lists every command and flag. The CLI exits 2 for this error. --- <!-- source: https://graft.page/docs/glossary --> <!-- section: Reference --> # Glossary > Plain definitions of the terms used across the Graft docs, each linked to the page that covers it. *What Graft's terms mean, in plain language.* Look up a term you met in the docs, an error message, or an agent's output. Each entry links to the page that covers it in full. ## Content and compiling **Collection.** A named group of documents that share fields, such as `pages` or `posts`. Declared in `graft.config.ts`. See [Schema](/docs/schema). **Slug.** A document's name inside its collection. `content/pages/about.mdx` has the slug `about`. **Frontmatter.** The block between the `---` lines at the top of a content file. Its fields must match the collection's schema. **MDX.** Markdown that can include components. Graft refuses the parts of MDX that can run code unless you allow them. See [MDX safety](/docs/mdx-safety). **Schema.** The description of each collection's fields, written in `graft.config.ts` with Zod. It checks files, function inputs, and reads. See [Schema](/docs/schema). **Compile.** Running `graft compile`: Graft checks every content file against the schema and builds the index. See [How Graft works](/docs/the-model). **Index.** The searchable copy of your content that your app reads from. It is built by compiling, and it can always be rebuilt from your files. **Derived.** Built entirely from something else. The index is derived from your files, so the files are the source of truth. **Project, projection.** What a compile does to the index: it makes the index match your files. You will see "project" in error messages such as `INDEX_OWNERSHIP`. **ChangeSet.** The list of documents a compile added, changed, or removed. Framework packages use it to refresh only the cached pages that changed. **`content_index`.** The Postgres table that holds the index on the Postgres engine. **Compilation trail.** The record of past compiles, with what each one changed. `graft compilations` and the `list_compilations` tool read it. ## Storage engines **Storage engine.** Where the index lives: a SQLite file (static) or a Postgres database. See [Storage engines](/docs/tiers). **Static engine, static index.** The index as a single SQLite file, `.graft/index.db`. Needs nothing running. **Postgres engine.** The index in a Postgres database. Needed for operational data, typed functions, branches, approvals, and Studio. **Artifact.** The static index file, `.graft/index.db`. It is generated, so it stays out of Git. ## Runtime data and access **Operational data.** Data your users create at runtime, such as form submissions, orders, and comments. Git cannot rebuild it, so it lives in Postgres. See [Operational data](/docs/operational-data). **File-authoritative, db-authoritative.** Whether a collection's source of truth is files in Git (the default) or rows in Postgres (`authority: "db-authoritative"`). **Typed function.** A function you declare in your config that reads or writes operational data, callable at `POST /api/fn/<name>`. See [Functions & access](/docs/functions-and-access). **Actor.** Whoever is making a request: a signed-in user, an agent, or anonymous. Graft works it out from the request's token. `FunctionActor` in code. See [Auth](/docs/auth). **Scope.** A permission an actor holds, such as `submissions:read`. Not to be confused with branch scope. **Approval.** A human sign-off that a destructive action, such as a delete, must wait for. See [Audit & approvals](/docs/audit-and-approvals). **Audit log.** A table with one row for every function call: who, what, when, and how it ended. See [Observability](/docs/observability). **Correlation id.** An id in the `x-graft-correlation-id` response header that links a user's report to the audit log row for that request. **Operator, runtime role.** The two database logins used after [hardening](/docs/hardening). The operator role stays with you or your CI. The runtime role is what your deployed app uses, and it can never approve its own destructive actions. ## Branches and changes **Branch (Graft branch).** A separate copy of the index, compiled from the files on a Git branch, used for previews. See [Branching & previews](/docs/branching-previews). **Overlay.** The default way to store a branch: its documents sit in your existing database, labelled with the branch name. **Neon fork.** A branch stored as a full copy of the database, made by Neon. **Tombstone.** A marker on a branch saying a document is deleted there. The main index is not affected. **Branch scope.** The set of documents a read on a branch can see: the branch's own changes plus everything it inherits. Not related to permission scopes. **Migration.** A file that updates existing content or data to a new schema shape. See [Migrations](/docs/migrations). **Codemod, backfill.** The two kinds of migration. A codemod rewrites content files. A backfill updates operational data rows. **Ledger.** The `migrations_applied` table, which records every migration that has run so none runs twice. ## Agents and extending **MCP (Model Context Protocol).** The open standard agents use to call tools. Graft serves its operations as MCP tools. See [Agents overview](/docs/agent-surface). **Tool.** One operation an agent can call, such as `write_content`. See [MCP tools](/docs/mcp-tools). **Resource, prompt.** Documents an agent can attach as context, and ready-made workflows a person can pick. See [Resources & prompts](/docs/mcp-resources-and-prompts). **`fix`.** The part of every Graft error that names the next step. Written so an agent can act on it. See the [Error reference](/docs/errors). **Primitive.** A ready-made feature, such as comments, that `graft add` copies into your project as code you own. See [Owned primitives](/docs/owned-primitives). **Registry.** The catalog of primitives that `graft add` and the `list_registry` tool read from. **Barrel.** `graft/index.ts`, a file that re-exports everything in `graft/`, so your config picks up added primitives automatically. **Studio.** The optional editing UI, built for people who do not use Git. Run it on your machine with `graft studio`, or host it for a team with `graft serve --studio`. Every content change it makes is also available from Git, the CLI or MCP. See [Studio](/docs/studio). **Draft.** Saved changes that are not live yet. In a local Studio they are the edited files. On a server that writes to GitHub, each identity drafts on its own branch, `graft-studio/drafts/<identity>`: a person signed in to Studio, or the identity an agent's token resolves to. Two connections with the same identity share one draft. **Publish.** Making drafts live. In a local Studio it is a commit in your checkout, which you push yourself. On GitHub it is one commit on the production branch, or a pull request when `GRAFT_STUDIO_PUBLISH` is `pull-request` or the person or token lacks `studio:publish` (`content:publish` for agents). See [Host Studio for your team](/docs/studio-hosting). --- <!-- source: https://graft.page/docs/upgrading --> <!-- section: Reference --> # Upgrading > Move a project to the latest Graft beta, with every breaking change since 0.x and what to change for each. *What changed between versions, and what you need to change.* Graft is in beta. Most releases need nothing from you, but some change a default to make it safer, and those can need a change in your project. This page lists every change that can break an existing project, newest first, with what to do about each one. For every change in a release, including fixes and new features, see the [Changelog](/docs/changelog). ## Upgrade your packages All `@usegraft/*` packages share one version number. Upgrade them together, naming every `@usegraft/*` package your project already uses and no others. For a Next.js project on Postgres, that might be: ```sh npm i @usegraft/core @usegraft/db @usegraft/sdk-next npm i -D @usegraft/cli ``` On another framework, swap `@usegraft/sdk-next` for your adapter, such as `@usegraft/sdk-astro`. Mixing versions is not supported. Then run `graft compile`. If anything in your project relies on an old behavior, the error's `fix` says what to change. > **Prompt for a coding agent:** > Upgrade every @usegraft package in this project to the latest version, > following https://graft.page/docs/upgrading.md. Run graft compile and fix > anything it reports. ## 1.0.0-beta.9 **`@usegraft/sdk-next` no longer supports Next.js 14.** Its peer range is `next@^15.0.0 || ^16.0.0`, and CI no longer tests 14. Next.js 14 gets no security fixes, and its last release has two critical remote code execution advisories. The adapter still runs on 14.2, so a project that cannot upgrade yet keeps working. Installing shows a peer warning, and npm refuses unless you pass `--legacy-peer-deps`. **What to do:** upgrade to Next.js 15.5.24 or 16.3.3 or later. Until you can, follow [Run Graft on Next.js 14](/docs/next#run-graft-on-nextjs-14). ## 1.0.0-beta.8 **Frontmatter written in JavaScript is refused.** A document whose frontmatter opens with `---js` or `---javascript` used to have that block run as code wherever the file was read, including the Studio server and CI. `graft compile`, Studio saves and MCP writes now refuse it with `INPUT_VALIDATION_FAILED`, and read YAML or JSON frontmatter only. See [MDX safety](/docs/mdx-safety#frontmatter-is-read-as-yaml-or-json). **What to do:** if a file opens with `---js`, rewrite its frontmatter as YAML. **Committing and reverting through the Studio API needs `studio:publish`.** `POST /changes/commit` and `POST /compilations/:id/revert` asked for `studio:write`, so a contributor could commit to the checkout or roll it back without the publish right that Studio's own Commit button checks. Both now answer `UNAUTHORIZED` without `studio:publish`. Studio's screens need no change. **What to do:** if your own code calls either route, give its token the `studio:publish` scope. **`graft compile` refuses a reference that points at nothing.** A `field.reference` whose slug names no document, such as a product whose category was deleted, stops the compile with `CONTENT_REFERENCE_NOT_FOUND`, naming the file, the field and the slug. Studio and MCP writes refuse the same thing before they write. See [Choices and references](/docs/schema#choices-and-references). **What to do:** run `graft compile` (or `graft check`). For each reference it names, create the missing document or point the field at one that exists. **If you call `checkRemoval` from `@usegraft/compiler` yourself,** pass `contentDir` and drop `db` and `branchId`. It checks against the files in `contentDir`, which stay right when the index is behind them. **Contributors can't commit on a local Studio.** On your own machine a commit lands on the checkout at once, with no review, so it now needs the editor role. A contributor's drafts wait, and the Commit button says why. A local commit is also authored by the person signed in, not by whoever runs Studio. **What to do:** nothing, unless a contributor used to commit locally. Give them the editor role, or have an editor commit their drafts. ## 1.0.0-beta.7 **Postgres projects need `graft db migrate`.** This release adds tables for Studio accounts and sessions, the media library, agent tokens, site settings, deploy hooks, content kept in the database, and the GitHub mirror. Until you migrate, a Studio page that needs one of them answers `MIGRATION_REQUIRED` with the fix, and the rest of Studio keeps working. **What to do:** run `graft db migrate` against each database, then `graft harden <role>` again if your app connects as a hardened runtime role. A static project has nothing to migrate. **Embedding `@usegraft/studio/panels` needs `lucide-react`.** Studio's icons moved from Phosphor to Lucide, and the panels import it as an optional peer. **What to do:** if you embed the panels, run `npm i lucide-react` and remove `@phosphor-icons/react` if nothing else uses it. `graft studio` and `graft serve --studio` need nothing. ## 1.0.0-beta.6 **Drafts made through an invite link move to a new branch name.** On a hosted Studio that saves to GitHub, a person who signed in with an invite link now gets a draft branch whose name can't collide with a GitHub login. A draft they saved before you upgraded stays on GitHub under the old name, and Studio no longer shows it. **What to do:** before upgrading, have invite-link editors publish or discard their drafts. A draft left behind is an ordinary branch named `graft-studio/drafts/…`: merge it by hand, or delete it. People who sign in with GitHub are not affected. ## 1.0.0-beta.5 **`GET` and `PUT /api/studio/v1/document` are removed, with the `DocumentDto` type from `@usegraft/studio`.** Studio's editor moved to `/entry` in this release. The new route reads and saves the same files, and it also saves to GitHub when Studio is [hosted for a team](/docs/studio-hosting). This affects you only if your own code calls the Studio HTTP API. The Studio UI, MCP tools and the CLI need no change, and old Studio links still open the right entry. **What to do:** change each call as follows. | Before | After | | --- | --- | | `GET /document?collection=…&slug=…` | `GET /entry?collection=…&slug=…` | | `sourcePath` in the response | `path` | | `PUT /document` with `{ collection, slug, data, body }` or `raw` | `PUT /entry` with the same fields, plus `baseVersion` | | `branch` in the `PUT` body | Not accepted. A save goes to the branch Studio serves | | `DocumentDto` | `EntryDto` | Send the `version` from `GET /entry` back as `baseVersion`. If the entry changed since you read it, the save is refused with `CONTENT_CONFLICT` (409) and nothing is written. Read it again and resend. A save without `baseVersion` overwrites whatever is there, as `PUT /document` did. `GET /entries` also changed: an entry deleted in a draft stays in the list with `status: "deleted"` until the deletion is published, and `GET /entry` returns its published copy. If you count or filter entries, skip that status. See the [Studio API](/docs/studio-api#routes). ## 1.0.0-beta.4 No breaking changes. Studio and agent writes can now refresh your app's cache. See [Refresh pages when content changes](/docs/next#refresh-pages-when-content-changes). ## 1.0.0-beta.3 **`@usegraft/sdk-next` needs Next.js 15.5.24 or 16.3.3 or newer.** Those are the first releases with fixes for the critical Next.js security advisories published in 2026. Anything older on either line, such as 16.3.0, is outside the peer range, and installing shows a warning like `incorrect peer dependency "next@16.3.0"`. The app may still build. The warning is about the advisories, not about Graft's code. **What to do:** upgrade Next.js. `npm i next@16` installs the latest 16.x, and `npm i next@15` the latest 15.x. Next.js 14 is not supported in this release: it no longer gets security fixes, and those advisories are still open on it. A later release runs on 14.2 again, with a warning. See [Check your Next.js version](/docs/next#check-your-nextjs-version). **`graft asset put` refuses a key that already holds a file.** It used to replace the stored file, and the store keeps no history, so the old file was gone. It now fails with `ASSET_EXISTS`, the same error the MCP `put_asset` tool returns. **What to do:** if a script uploads to the same key on purpose, add `--overwrite`. Otherwise give each upload a new key. ## 1.0.0-beta.1 and 1.0.0-beta.2 No breaking changes. These releases raise dependency versions past known security advisories and fix security findings. ## 1.0.0-beta.0 **The approval policy moved from an environment variable into your config.** `GRAFT_APPROVAL_POLICY` is ignored, and `graft serve` warns once at start if it is still set. Move it to `graft.config.ts`: ```ts // before: GRAFT_APPROVAL_POLICY=unattended in .env export const approvalPolicy = "unattended"; ``` An unknown value is refused rather than replaced with a default, so a typo cannot quietly weaken the policy. See [Audit & approvals](/docs/audit-and-approvals). **If you call `createClient` from `@usegraft/sdk-core` directly with a `db`,** it no longer takes one. It reads a compiled index (`index`) only. Pass your Postgres handle to `createDbClient` from `@usegraft/sdk-core/db` instead: ```ts // before: createClient({ db, collections }) import { createDbClient } from "@usegraft/sdk-core/db"; export const client = createDbClient({ db, collections }); ``` Framework adapters' `createGraft` still accepts either, so app code that goes through an adapter needs no change. Other changes in this release need no action from you, but change behavior: - An approval used on an unattended call is now marked as used, so it cannot authorize a later call if you tighten the policy. - `run_function` over MCP now gets the same default rate limit as `POST /api/fn` under `graft serve`. - MDX that cannot be parsed now returns `INPUT_VALIDATION_FAILED` from `write_content` and Studio saves, instead of a plain error. ## From 0.x to 1.0 0.x is deprecated. The 0.2.0 release tightened security defaults, and each item below can stop an older project from working until you change it. ### Agents over HTTP need an identity MCP over HTTP now refuses anonymous callers by default. - `requireActor` is replaced by `allowAnonymous`, which defaults to `false`. - `createGraftMcpHandler` refuses to start with neither an `actor` resolver nor `allowAnonymous: true`. - `graft serve` allows anonymous agents only when it listens on your own machine, or when you opt in with `GRAFT_MCP_ALLOW_ANONYMOUS=1`. Use the opt-in only behind your own authenticating proxy: it opens the MCP tools to anyone who can reach the server. `decide_approval` still refuses anonymous callers. `graft serve` warns at start when the opt-in is set and it listens beyond your own machine. **What to do:** give your HTTP endpoint an `actor` resolver. See [Auth](/docs/auth) and [Connecting agents](/docs/connecting-agents). ### Permissions are scopes, not just "signed in" - `write_content`, `put_asset`, and `delete_content` require the `content:write` scope. `graft mcp` on your own machine has it automatically. - Studio routes require `studio:read`, `studio:write`, or `approvals:decide`. - Studio's `authorize` option is replaced by `authenticate`, which returns the caller and their scopes. **What to do:** add the scopes your agents and users need to their tokens. See [Studio API](/docs/studio-api) for `authenticate`. ### Approvals are recorded against a verified identity - `decide_approval` (MCP) no longer accepts `decidedBy`, and the Studio API ignores it. - `decideApproval(db, id, decision, decidedBy)` now takes an `ApprovalDecider`. - A function that needs approval refuses callers with no stable identity, since nobody could later tell who asked. **What to do:** remove `decidedBy` from any code that decides approvals. ### Executable MDX is refused `graft compile` now fails on MDX containing `{…}` expressions, `import`, `export`, or spread attributes (`<Card {...props} />`), and on MDX it cannot parse. **What to do:** remove executable MDX from your content, or, if every author has commit access to your repository, set `mdxTrust = "full"`. See [MDX safety](/docs/mdx-safety). ### Rate limits use the real client address `x-forwarded-for` is ignored unless you set `trustedProxyHops`, and the old `x-graft-peer` header is gone. **What to do:** behind a proxy or CDN, set `trustedProxyHops` to the number of proxies. See [Rate limits](/docs/rate-limits#if-your-app-is-behind-a-proxy-or-cdn). ### Hardened roles need one new permission The runtime role now needs `UPDATE (status, duration_ms)` on `audit_log`. **What to do:** run `graft harden <role>` again with the operator login. ### Safer file handling - `put_asset` over MCP only reads files inside your project, and only on a local server. Remote agents send the file's contents instead. - Studio and the registry refuse paths that escape their folders, including through symlinks. - The local Studio refuses requests from other sites. See [Studio API](/docs/studio-api#requests-that-change-data). **What to do:** nothing, unless you relied on reading files outside the project. ## Full changelog Every release's complete notes are in each package's `CHANGELOG.md`, for example [`packages/cli/CHANGELOG.md`](https://github.com/AndersonDesign1/graft/blob/main/packages/cli/CHANGELOG.md). --- <!-- source: https://graft.page/docs/changelog --> <!-- section: Reference --> # Changelog > Every change in every Graft release, newest first, with the packages it touched. Dependency-only updates are left out. *Every change in every release, newest first, except dependency-only updates.* All Graft packages release together under one version number, so each release below covers all of them. Each change names the packages it touched and links to its commit. To move a project across a breaking change, follow [Upgrading](/docs/upgrading), which says what to change for each one. There are 12 releases and 121 changes on this page. ## 1.0.0-beta.8 Released October 19, 2026. ### Breaking changes - **BREAKING:** `graft compile` now stops on a `field.reference({ to })` that names a document that doesn't exist, with `CONTENT_REFERENCE_NOT_FOUND`. Until now a reference was checked only by `graft check`, so a product whose category had been deleted compiled, and the site found the gap at render time. A project that compiled with a broken reference fails now. Run `graft check` to list every one, then fix them or create the missing documents. The error names each file, field and missing slug in `details.references`: ```txt Error CONTENT_REFERENCE_NOT_FOUND: 1 reference(s) name a document that doesn't exist: products/wool-hat.mdx "category" points at categories/hats. ``` Both content sources make the check, because both go through `validateDocuments`. In a database-sourced project, a publish refuses a draft whose reference isn't live, and refuses to publish the deletion (or a new slug) of a document that live documents still point at. Publish them together, changed to point elsewhere. A local Studio checks the same way: a save of an entry whose reference isn't in the index is refused, and so is deleting an entry other entries point at, before its file is removed. `checkRemoval` in `@usegraft/compiler` is that second check, and `compileDocuments` makes the first. `validateDocuments` takes a new `references` option. `"files"` checks targets among the files given, which is right for a whole tree, and `readDocs` uses it by default. A set of `collection/slug` keys also counts those as existing, for a caller checking part of a larger whole. Without the option, references are not checked. `liveDocuments` in `@usegraft/db` lists a branch's live documents for that set. `cli`, `compiler`, `db`, `mcp` · [b56d6b1](https://github.com/AndersonDesign1/graft/commit/b56d6b1) - **BREAKING:** frontmatter is read as YAML or JSON only. gray-matter runs a `---js` frontmatter block through `eval`, so a contributor's Studio save, an agent's `write_content`, or a pull request built by `graft compile` could run code on the machine reading it. Every read now goes through `readFrontmatter` from the new `@usegraft/mdx-safety/frontmatter` entry, which refuses any other language with `INPUT_VALIDATION_FAILED` before parsing, whatever `mdxTrust` is. A lint rule stops gray-matter being imported anywhere else. **BREAKING:** `POST /api/studio/v1/changes/commit` and `POST /api/studio/v1/compilations/:id/revert` need `studio:publish`. They asked for `studio:write`, so a contributor could commit to the checkout, or roll it back, around the check Studio's Commit button makes. Also fixed: - `graft studio` with `GRAFT_DEV_TOKEN` gives the operator every admin scope, as its documentation says. Since contributors were stopped from committing locally, the token's missing `studio:publish` stopped the operator committing too. - Without a database, a media upload or delete is refused before the bucket is touched. Both used to change the bucket, then answer `NEEDS_DATABASE`. `cli`, `compiler`, `content-migrations`, `mcp`, `mdx-safety`, `studio` · [70321c7](https://github.com/AndersonDesign1/graft/commit/70321c7) ### Features - `graft studio` starts without a database. Until now it stopped with `ENV_VAR_MISSING: DATABASE_URL is not set`, so a static project (`export const index = "static"`) could not use Studio at all, and a Postgres project could not open it to fix a typo without a running database. Without `DATABASE_URL` you can find, edit, create, delete and commit content as before. In a static project each save also updates `.graft/index.db`, and Developer, Health compares the files with it; a static project never uses a database even when one is set, as with `graft mcp`. The views whose data lives in Postgres (approvals, the compile history, branches, people, agents, the media library, site settings) say they need a database and how to add one, and their API routes answer `NEEDS_DATABASE` (HTTP 503) with the same steps. For `createStudioHandler`, `db` is now optional, and `staticIndexPath` points a Studio at the static index: ```ts createStudioHandler({ collections, contentDir, staticIndexPath: ".graft/index.db", }); ``` `GET /workspace` reports `database` and `index` (`"postgres"`, `"static"` or `"none"`), and `GET /tree` reports which index its states are measured against. Sign-in is unchanged. A `graft studio` on `127.0.0.1` asks nobody to sign in, with or without a database. Studio accounts and their sessions are kept in Postgres, so they still need one, and `graft serve --studio` still requires `DATABASE_URL`. `cli`, `studio` · [f1ed5d2](https://github.com/AndersonDesign1/graft/commit/f1ed5d2) - References are checked before a write, not after it. A local Studio save, bulk edit, delete or discard, and MCP's `write_content` and `delete_content`, used to write or delete the file and then check references while updating the index. A refusal said nothing was saved while the file had changed, and the index no longer matched it. Now a refused write leaves every file as it was. - **`checkChange`** in `@usegraft/compiler` checks a change to a few files before it is written: the checks a compile makes of them, references included, and that nothing left as it is points at a document the change deletes or renames. It checks against the Postgres index, or the files when there is none. - **A stale index no longer refuses a good save.** A reference may name a file in the content folder the index hasn't caught up with, such as one a `git pull` brought in. - **On the static index or with none,** deletes and discards are checked too, and a reference already broken somewhere else no longer refuses an unrelated save. `graft compile` still reports it. - **`checkRemoval` takes `contentDir`,** and checks against the files there when it is given no `db`. - **Publishing in a database-sourced project checks references once it holds the content lock.** Two publishes at once, one deleting a category and one pointing a product at it, could each pass against the state before the other. `publishDrafts` in `@usegraft/db` accepts the index rows as a function of the live documents for this. - **A bulk edit is checked as a batch before anything is written,** and sets a field on at most 500 entries at once. A file that fails to write partway is reported with the others, and the ones written still reach the index. `compiler`, `db`, `mcp`, `studio` · [36472f3](https://github.com/AndersonDesign1/graft/commit/36472f3) - A delete or a slug change checks references against the files, not an index that is behind them. After a `git pull` with no compile, a Studio or MCP delete could remove a document a pulled one pointed at, and the next `graft compile` failed. It could also refuse a delete over a document already gone from the files. Ordinary saves still check against the index, which keeps them fast. `checkRemoval` from `@usegraft/compiler` reads only the files: pass `contentDir` and drop `db` and `branchId`. Studio fixes from the review: - Set a field opens ready to set a value. Removing a field from every selected entry is a separate checkbox, so an empty value no longer removes it on one Enter. - The back link from an entry returns to the list as you left it: search, filters and sort. - A filter that came from a link, on a field with no filter menu, shows as a chip you can remove. It used to narrow the list with nothing on screen saying so. - A disabled Commit button says why on hover, on keyboard focus and to a screen reader, not only in a tooltip that never showed. - Home folds repeated saves into one row only within an hour of each other, so Monday's edits don't vanish under Friday's. - After a partial bulk failure, the selection keeps only rows the list still shows, so its count matches what the next action does. - A refresh that fails says the rows on screen are from before. - Opening a list, or Health and Settings, reads it once, not twice. `compiler`, `mcp`, `studio` · [e61014f](https://github.com/AndersonDesign1/graft/commit/e61014f) - A local Studio save now updates the index for the file it wrote, instead of recompiling the whole content tree. On a 2,000-product catalog a save took 1.6 seconds and now takes about 100 milliseconds. The save checks the file with the same rules as `graft compile` (the schema, MDX safety, unique slugs) and records a compilation with the commit it was read at, so the compile history still lists it. Other files are left as they are: a file changed outside Studio reaches the index on the next `graft compile`. One broken file elsewhere in the tree no longer makes every save fail. Two functions make this available outside Studio: ```ts import { compileDocuments } from "@usegraft/compiler"; // Read, validate and project two files; a missing one comes off the index. await compileDocuments({ contentDir, collections, db, paths: ["products/wool-hat.mdx"], }); ``` `projectDocuments` in `@usegraft/db` is the projection underneath, and it is the one a database-sourced publish already used. It changes only the rows it is given, refuses a slug another live file holds with `SLUG_NOT_UNIQUE`, and removes a row whose file no longer produces it. `compiler`, `db`, `studio` · [41c371f](https://github.com/AndersonDesign1/graft/commit/41c371f) - `autoJoinAccount` creates a Studio account for an email that has never had one, with a given role. It returns null for an email that has or had an account, so someone an admin removed is not brought back, and null before the owner exists, so the first visit can still claim the Studio. `db` · [d854102](https://github.com/AndersonDesign1/graft/commit/d854102) - `acceptInviteForEmail` uses the newest open Studio invitation for an email, for someone who proved they own the address another way, such as a verified email from GitHub, Google or single sign-on. It only creates or restores an account: for someone who can already sign in, an invitation is a password reset and still takes its link. An account made that way stores `NO_PASSWORD_HASH`, and `StudioAccount.hasPassword` says whether an account has a password. `db` · [ef9af08](https://github.com/AndersonDesign1/graft/commit/ef9af08) - `@usegraft/sdk-next` runs on Next.js 14.2 and later, and in the Pages Router as well as the App Router. The peer ranges are now `next@^14.2.0 || ^15.0.0 || ^16.0.0` and `react@^18.2.0 || ^19.0.0`. - **The Pages Router.** Read in `getStaticProps` or `getServerSideProps` as before. `prepareMdx(body)` compiles a body there, with the same trust rule as `MdxBody`, and `MdxContent` renders the result in the server render and in the browser. It comes from a new entry, `@usegraft/sdk-next/mdx`, that carries no server code. A body that uses `import` is refused with `INPUT_VALIDATION_FAILED`, since the browser has no bundler to resolve it. - **React 18.** `createGraft` uses `React.cache` where React has it, and reads straight through where it does not, as in the Pages Router on Next.js 14. - **Next.js 14.** `withGraft` writes `experimental.serverComponentsExternalPackages` there, the name 14 reads, and `serverExternalPackages` on 15 and later. The adapter imports `next/cache.js`, which Node's own loader can resolve when the Pages Router loads it unbundled. - **Advisories.** Releases with open critical Next.js advisories are no longer refused at install. `withGraft` prints one warning naming the release to upgrade to: any 14, 15 before 15.5.24, or 16 before 16.3.3. Next.js 14 gets no more security fixes. Its last release, 14.2.35, is affected by 23 published advisories, two of them critical remote code execution, and each is fixed only in 15 and 16. Support for 14.2 is there so you can adopt Graft before you upgrade. CI now builds and serves a real Next.js 14.2.35 app beside 15 and 16, and on each checks a Pages Router page and that none of the scripts it sends to the browser carries the database, the index or the MDX compiler. The Next.js guide has a version table and a section on the Pages Router. `mcp`, `sdk-next` · [d06bbc3](https://github.com/AndersonDesign1/graft/commit/d06bbc3) - `GRAFT_STUDIO_AUTO_JOIN` lets a whole email domain join a hosted Studio without invitations: `GRAFT_STUDIO_AUTO_JOIN="shop.example.com=contributor"`. The first time someone signs in with GitHub, Google or single sign-on and has a verified email at the domain, Studio makes them an account with the domain's role (contributor by default), shown in Settings, People, where an admin can change or remove it. Someone removed stays out. Admin is never given this way, free mail providers such as gmail.com are refused, and nobody joins before the owner exists. People lists the domains that join on their own. `studio` · [d854102](https://github.com/AndersonDesign1/graft/commit/d854102) - A hosted Studio can sign people in with Google, and with any other OpenID Connect provider: Okta, Entra ID, Keycloak, your app's own Better Auth (with `@better-auth/oauth-provider`) and others. Set `GRAFT_GOOGLE_CLIENT_ID` and `GRAFT_GOOGLE_CLIENT_SECRET` for "Continue with Google", or `GRAFT_OIDC_ISSUER`, `GRAFT_OIDC_CLIENT_ID` and `GRAFT_OIDC_CLIENT_SECRET` (and `GRAFT_OIDC_NAME` for the button) for another provider. Studio runs the authorization code flow with PKCE and checks the ID token's signature, issuer, audience, expiry and nonce, and only a verified email counts. Settings, People is now the one place to add someone, however they sign in. A person invited there signs in with GitHub, Google or single sign-on as their Studio account, and their first sign-in uses the invitation: no link to pass on, no password to choose. GitHub matches any verified address on the person's account. Someone signed in as an account with no password sees no password to change in Settings. `studio` · [ef9af08](https://github.com/AndersonDesign1/graft/commit/ef9af08) - Set one field on many entries at once. Select entries in a collection's list and choose **Set a field**: pick the field and its value with the same control the entry editor uses, and Studio saves each entry as a draft. Nothing goes live until it is published. Entries that can't be saved are listed in the dialog with the reason and stay selected; the rest are saved. The API behind it is `POST /api/studio/v1/entries/field`: ```json { "collection": "products", "slugs": ["wool-hat", "linen-shirt"], "field": "status", "value": "archived" } ``` Each entry is validated and written exactly as `PUT /entry` would, against the version read just before the write. `"value": null` removes an optional field. A local Studio updates the index once for the whole batch. The answer lists `saved` entries with their new versions and `failed` entries with their error code, message and fix. On a phone, the bulk bar's buttons now wrap instead of running off the screen. `studio` · [6161f0e](https://github.com/AndersonDesign1/graft/commit/6161f0e) ### Fixes - `tools/list` no longer fails with "Cannot read properties of undefined (reading 'push')" when the MCP SDK resolves an older zod than Graft's. The SDK takes any zod from 3.25 on, so a project whose own dependencies bring zod 4.2 to 4.4 handed the SDK a converter that crashed on Graft's schemas. Calling a tool by name still worked, but standard clients list tools first, so none of them could connect. The listing is now converted with the zod that built the schemas, and is otherwise the SDK's own, field for field. `serverInfo` reports the Graft release instead of `0.0.0`. Token messages speak to whoever holds the token. A revoked or unknown Studio token is told to get a live one from Developer, Agents, not to configure `createActorResolver`. A token missing a scope is told which access level carries it. An anonymous caller is told where tokens come from. `graft serve` beyond loopback no longer warns that MCP will refuse every caller: tokens made in Studio always work. It now says how callers get in. On `graft studio`, the Agents page no longer shows an MCP endpoint on this computer, which answered 404. It shows `npx graft mcp` for an agent on this machine, and `/api/mcp` wherever `graft serve` runs. `auth`, `cli`, `mcp`, `studio` · [c9868b0](https://github.com/AndersonDesign1/graft/commit/c9868b0) - Studio no longer loops silently when opened over plain http on an address other than localhost. The session cookie is `Secure` there, so the browser dropped it and sign-in landed back on the form with no message. Studio now says sign-in needs https, or localhost, instead of showing a form that can't work, and an invite link stays unused until it can sign someone in. `graft doctor` and `setup_status` no longer make a missing `GRAFT_STUDIO_SECRET` the next step: it only matters once the site is online. That check now has its own id, `studio-secret`. With nothing else to do, doctor says the project is ready to work on and that the `!` lines are for going online. Once the secret is set, creating the owner account is still the next step. `graft --help` lists `graft studio setup-code` and `graft studio invite`. `cli`, `compiler`, `studio` · [9d26068](https://github.com/AndersonDesign1/graft/commit/9d26068) - Studio's Publish button no longer says "Publish" for a moment before it says Commit or Submit for review. It now reads what it does from the workspace, which answers at once, instead of guessing until the drafts load. A collection's search, filters and sort are kept in the address, so a reload, the back button or a shared link shows the same rows. Its count, and Home's, no longer include entries deleted in a draft: they stay listed as Deleted until the deletion is published. Home's recent activity folds a run of the same person changing the same entries into one row ("Kyrus, 3 times"). Health and Settings read the content again after every save, publish, discard or delete, so their counts are never from before the last change. Studio has a favicon, so browsers stop asking the server for `/favicon.ico`. An agent's `publish_drafts` that commits to the production branch on GitHub now calls the site's deploy hooks under `graft serve`, as a database publish does. `createGraftMcp` takes an `onPublished` callback for this. A hook that fails never fails the publish. `cli`, `mcp`, `studio` · [9571702](https://github.com/AndersonDesign1/graft/commit/9571702) - A save keeps the comment after an edited value. Changing `title: "Old" # shown in the tab` in Studio or over MCP used to write `title: "New"` and lose the note; it now writes `title: "New" # shown in the tab`. This holds for quoted and plain one-line values. A value that becomes a list or a map drops the comment, because it no longer has one line to sit on. `compiler` · [d016963](https://github.com/AndersonDesign1/graft/commit/d016963) - A save no longer turns a one-line string into a folded `>-` block. js-yaml wrapped any string longer than 80 columns, so editing a long `description` rewrote it across three lines. Strings now stay on one line, and an edited one-line string keeps the quotes it had (`"…"` or `'…'`). A frontmatter that opens with a comment is now patched key by key like any other, instead of being rewritten whole, which dropped the comment, re-quoted every string and lost the blank line after `---`. Comments and blank lines between keys stay put when the key above them changes. `compiler` · [de7e5d6](https://github.com/AndersonDesign1/graft/commit/de7e5d6) - Requires `@modelcontextprotocol/sdk` 1.31 or newer. Older versions could send an OAuth client's credentials to the wrong authorization server, and pulled in `proxy-addr`, `hono` and `ip-address` versions with known advisories. `mcp` · [5553879](https://github.com/AndersonDesign1/graft/commit/5553879) - An agent's `write_content` and `delete_content` now update the index for the file they wrote, instead of recompiling the whole content tree, as a Studio save does. On a 2,000-product catalog a `write_content` call took about 1.5 seconds and now takes about 110 milliseconds. The result's `changes` lists that one document. The static index is still rebuilt whole, and content kept in the database is unchanged. `mcp` · [41c371f](https://github.com/AndersonDesign1/graft/commit/41c371f) - Fixes from a review of the Next.js 14 and Pages Router support. - **Unsafe URLs are dropped from restricted MDX.** `MdxBody` and `prepareMdx` remove any URL attribute whose scheme isn't `http`, `https`, `mailto` or `tel`, on Markdown links and on components. A `[Read more](javascript:…)` link rendered as a live link on React 18, which the Pages Router on Next.js 14 uses. - **`prepareMdx` reads imports from the syntax tree.** A code span or fenced block that only mentions `await import(` is no longer refused. Under `trust: "full"`, `import()` and `import.meta` are refused too, instead of compiling to code that always throws. - **`withGraft` merges both externals settings into the one this Next.js reads.** On 15 and later, a leftover `experimental.serverComponentsExternalPackages` no longer replaces `serverExternalPackages` and drops `@usegraft/registry`. On 14, a top-level `serverExternalPackages` is moved where 14 reads it. - **`withGraft` finds Next.js from a monorepo's root.** When no `next` is installed beside the config, it uses the one this package resolves. - **A prerelease of a patched release counts as before it,** so `16.3.3-canary.1` gets the upgrade warning. - The `FRAMEWORK_VERSION_UNSUPPORTED` fix says `revalidateContent` works on Next.js 14, 15 and 16. The Next.js guide says `MdxContent` needs `'unsafe-eval'` in a Content Security Policy. `sdk-next` · [334deef](https://github.com/AndersonDesign1/graft/commit/334deef) - Lists can be walked from the keyboard. In a collection, j and k move through the entries from anywhere on the page (not while typing), and the arrow keys, Home and End once focus is in the list; Enter opens one. The list beside an open entry takes j and k too. Forms fit what they hold: - A string field that holds an address or a path (`url`, `githubUrl`, `thumbnail`, `email` and the like, or one with an `^https?` pattern) is a one-line input even when its limit is long. A URL allowed 2,048 characters used to open as a tall textarea. - The create dialog asks for the title first, then the rest in the schema's order. - A required group whose fields are all optional starts empty in the create dialog, so it no longer blocks creating an entry with "Links is required." until one of its optional fields is filled. On a phone, an entry's bar shows whether the save landed, and drops its own Commit button (the top bar's commits this entry with the rest) and the title, which the heading below shows in full. `studio` · [48e07b9](https://github.com/AndersonDesign1/graft/commit/48e07b9) - A contributor on a local Studio can no longer commit. Locally there is no review step, so a commit landed on the checkout at once, whatever the role. The contributor's drafts now wait for an editor: the Commit buttons are off with the reason, and the API refuses with `UNAUTHORIZED` and `details.required: "studio:publish"`. A local commit is now authored by the signed-in person instead of whoever runs Studio, who stays the committer. With no message, it names the entries by title (`Update “Wool Hat”`) instead of by path, and the Publish sheet's placeholder shows that message. A refusal now speaks to whoever was refused. A person is told their role can't do it and to ask an admin. A token is told the scope it lacks and where to make one that has it. Studio shows these messages instead of "You're signed out". `studio` · [8a5c753](https://github.com/AndersonDesign1/graft/commit/8a5c753) - Studio loads its editors when an entry opens, not with the app. Milkdown and CodeMirror were about 2.7 MB of the 3.3 MB every Studio page downloaded before it could show the home screen or a list. Now the first page loads 0.66 MB (201 kB gzipped), and opening an entry adds the editor (1.66 MB, 532 kB gzipped); the languages a code block highlights load only when one asks for them. The same split applies to `@usegraft/studio/panels`, whose bundle goes from 4.8 MB to 0.5 MB with the editor in a chunk of its own. `studio` · [118a339](https://github.com/AndersonDesign1/graft/commit/118a339) - Settings, Keyboard lists the list shortcuts: J and K move down and up a collection's entries, the arrow keys, Home and End move through a list once it has focus, and Enter opens the selected entry. `studio` · [0263253](https://github.com/AndersonDesign1/graft/commit/0263253) - An agent's request in the Inbox is titled with what it would do, such as "Delete projects/welup-digital", instead of the tool's name, and Approve asks once more before the agent may run it. The embedded `ApprovalQueuePanel` brings its own confirmation dialog. Deploy hooks are now called after Studio publishes to the production branch on GitHub, as they already were after a publish of content kept in the database. A commit on your own machine reaches no site and still calls none, and Settings now says so. Wording that didn't match what happens: - Deleting on your own machine says the file is removed now and the deployed site keeps it until you commit and push, instead of "stays live until you commit". - An entry nobody has edited since its last commit is dated by that commit, not by its file's modification time, which a clone or checkout resets. - The empty Drafts tab says there are no drafts instead of "Nothing matches." - Shortcuts in Settings and tooltips show Ctrl off a Mac, as the top bar already did. - The entry's details no longer show a "Version" that was the file's content hash. - An asset field's placeholder and an asset key error no longer name the shop example, and a missing key says it is missing instead of `"null" is not an asset key.` `studio` · [db2d4b6](https://github.com/AndersonDesign1/graft/commit/db2d4b6) - Studio fixes from a review of the last release's UI work. - **The editor's menus and inline code look like Studio again.** Since the editor started loading on demand, Milkdown's stylesheet arrived after Studio's and won ties: the slash menu had no background or shadow, and inline code lost its font and fill. It now sits in a cascade layer under Studio's own styles. - **The publish sheet no longer re-renders without end while drafts load.** On a slow GitHub workspace it spun until the drafts answered, and forever if they never did. It shows a placeholder, or the error with Try again, and names the action from the workspace as the top bar does. - **A failed workspace request shows the error and Try again** in Agents, Branches, History, Media and the Inbox, instead of a placeholder that never ends. - **Deleting several entries goes past one that is refused,** says how many were deleted, keeps the refused ones selected, and refreshes the list either way. - **A bulk edit cut off partway refreshes the list** and says some entries may have been saved. - **If the editor's code fails to load, only the editor says so,** with Reload, and the entry's fields keep working. - **Keyboard:** j and k no longer move focus behind a confirm dialog. Arrow keys from a row's checkbox move from that row, not from the first. - **Words:** - Errors read as two plain sentences, with no em dash. - The "add a database" steps include removing `index = "static"`. - Health shows no drift columns when there is no index. - Media doesn't offer uploads without a database. - Discard on entries with no drafts says there is nothing to discard. `studio` · [22c6e47](https://github.com/AndersonDesign1/graft/commit/22c6e47) ## 1.0.0-beta.7 Released October 13, 2026. ### Breaking changes - Studio now uses the docs' type, color and icons. IBM Plex Sans and Plex Mono replace Geist and Instrument Serif, and mono is kept for identifiers (a slug, a path, a field name) instead of labels. Labels are sentence case with no letter-spacing, nothing is under 13px, and weights are 400, 500 and 600. Color follows the docs' rule, monochrome chrome and colorful content: states are a mark and a word on the neutral ramp, amber is kept for something waiting on a person, red for errors and deletes, and diffs keep their code colors. Collections are marked by an icon in the text color, not a tinted tile. Icons are Lucide, at 16px and a 1.75 stroke, as in the docs. **BREAKING (panels embed only):** if you embed `@usegraft/studio/panels`, install `lucide-react` in place of `@phosphor-icons/react`. It is the optional peer the panels now import. `graft studio` and `graft serve --studio` need nothing. `studio` · [132107f](https://github.com/AndersonDesign1/graft/commit/132107f) ### Features - Studio gets a media library, agent tokens, and the site's settings with deploy hooks. - **Media.** A Media page lists the files in the asset store that Studio uploaded or an entry uses. Upload, search, write alt text, see which entries use a file, and delete one nothing uses. Image fields have Choose…, and a gallery's Add image, which pick from the same library and take the file's alt text when the field has none. New routes under `/api/studio/v1/assets`. - **Agents.** Admins make a token per agent under Developer, Agents, with Read, Write drafts or Publish access. The token is shown once and only its hash is stored. `graft serve` accepts it on `/api/mcp`, the Studio API and functions, and revoking it takes effect on the next request. No token can decide approvals. `@usegraft/auth`'s resolver gains a `tokens` option for this. - **Site and deploy hooks.** Settings has the site's name and address, and deploy hooks: URLs called with a `POST` after every publish of database-sourced content, with an optional bearer secret. Each hook shows what it answered last, and Send a test calls it now. A failing hook never undoes a publish. `@usegraft/compiler` exports `fireDeployHooks`. - New scope `studio:settings`, held by admins. - A Studio route that needs a table the database doesn't have answers `MIGRATION_REQUIRED` (503) with the fix, instead of failing the request. Run `graft db migrate` (migration 0013 adds `assets`, `agent_tokens`, `site_settings` and `deploy_hooks`). Run `graft harden <role>` again if you use a runtime role. `auth`, `cli`, `compiler`, `db`, `studio` · [b086a62](https://github.com/AndersonDesign1/graft/commit/b086a62) - Content can now live in your Postgres database instead of in git. Add `export const content = { source: "database" }` to `graft.config.ts` and run `graft content import` to load your files once. Studio and agents then save drafts to the database, and publishing puts a change on the live site straight away, with no rebuild and no GitHub setup. A hosted Studio needs only `DATABASE_URL`. Every publish is checked exactly as `graft compile` checks a file (the schema, slug uniqueness and MDX safety), and a draft that would fail compile never goes live. `graft compile` reads the published versions, never the files. There is one draft per document, shared by everyone who edits it. Two people or an agent saving the same document from the same version can't overwrite each other: the second save gets `CONTENT_CONFLICT`. Someone without publish rights submits for review instead, and the request closes once everything in it is live. Git stays the default and works as before. A project uses one source or the other, never both. Reverting to a commit, `graft merge` and content migrations work on files, so in a database-sourced project they refuse with the new `CONTENT_SOURCE_MISMATCH`. `graft db migrate` adds three tables (`content_revisions`, `content_heads`, `content_reviews`), and `graft harden` grants the runtime role access to them. For code that reads review requests from a content store: `ReviewRequest` now has an `id`, and its `number` and `url` are optional, since a review in the database is not a pull request. The `review` that MCP `list_drafts` and `publish_drafts` return follows the same shape. `cli`, `compiler`, `contracts`, `db`, `mcp`, `studio` · [c997e35](https://github.com/AndersonDesign1/graft/commit/c997e35) - A new site in one command, and a checklist that says what to do next. - `npm create graft` (the new `create-graft` package) runs `graft create`: it asks what kind of site (blog, docs, shop, portfolio or blank), how people will edit it (online in Studio, or as files in git) and which database to use, then writes the site with a Next.js app, installs it, migrates, loads the example content and compiles. Every question has a flag, and `--yes` takes the defaults, so an agent can run it with no prompts. - `--database new` makes a free Postgres with no account. It lasts 72 hours unless claimed: `graft db claim` prints the link, waits for the claim, and saves the new connection string to `.env`. Any Postgres 13 or newer works instead. - `graft doctor` and the MCP tool `setup_status` check the database and its version, the schema, a database waiting to be claimed, content, the index and Studio sign-in, and end with the one thing to do next. - A new site gets an `AGENTS.md`, and the docs serve a setup guide for agents at `/start.md`. `cli`, `compiler`, `create-graft`, `mcp` · [95ecc3e](https://github.com/AndersonDesign1/graft/commit/95ecc3e) - Moving content between the database, your files and git. - `graft pull` writes what is published into your files, and `graft push` publishes the files you changed. Graft remembers what each file was pulled at (`.graft/sync.json`), so a pull never overwrites your changes, and a push is refused with `CONTENT_CONFLICT` if someone published that document since. `--force` settles a conflict either way. - `graft dev` tells you when someone publishes, so you know your files are behind. Agents get the same answer from the new MCP tool `sync_status`. - `content.mirror` copies what is published into GitHub: in batches from `graft serve` (`mode: "batch"`, every `everyMinutes`), or after every publish (`mode: "each"`). `graft mirror` does one run. A batch is one commit, or one pull request merged straight away when the branch allows it. A pull request that waits on checks or a review stays the only one: later runs add to it until it merges. - `graft check` runs every check a publish makes on the files, with no database, and now also finds references to documents that don't exist. - `graft ci changes`, `graft ci skip-build` and `graft ci init` keep CI cheap on content-only changes. `skip-build` compares with the previous deploy, and builds when it can't tell which that was. For git-sourced projects hosted on GitHub, some publishes now open a pull request even for an editor: deletions and renames, and changes over 200 lines. Studio says why. Change the rule with `content.review` (`deletes`, `collections`, `overLines`), or turn it off with `review: false`. `graft db migrate` adds the `content_mirror` table. `cli`, `compiler`, `db`, `mcp`, `studio` · [a761a13](https://github.com/AndersonDesign1/graft/commit/a761a13) - Studio accounts: people sign in to a hosted Studio with an email and a password. - The first visit creates the owner account. On a server anyone can reach, it asks for a setup code first, which `graft serve` prints when it starts and `graft studio setup-code` prints from the site's settings. - Admins invite people from Settings, People: an email and a role make a link to pass on. The same list changes roles, makes a link to set a new password, and removes access, which signs that person out at once. - Account sessions live in the database, so signing out, removing someone or changing their role takes effect on their next request. Changing your password signs out your other browsers. Five wrong passwords in a row lock an account for 15 minutes. - Admins get the new `studio:people` scope. GitHub sign-in and `graft studio invite` links work as before. - New tables `studio_users`, `studio_sessions` and `studio_invites`: run `graft db migrate`, and `graft harden <role>` again if you use a runtime role. `cli`, `db`, `studio` · [2d97fda](https://github.com/AndersonDesign1/graft/commit/2d97fda) - Studio keeps each entry's history, puts every decision in one inbox, and shows what was published lately. - **History.** The entry's panel has a History tab. It lists every published version with who made it and when. Open one to see how the entry differs from it now, then restore it as a draft. History comes from the published revisions when content is in the database, the production branch's commits on GitHub, and the checkout's commits locally. New API routes: `GET /entry/history` and `GET /entry/version`. - **Inbox.** Changes sent for review and agents asking before a destructive call share one inbox, with a count in the sidebar. When content is in the database, someone who can publish publishes a review from there. Approvals move out of the Developer views, and old `#/dev/approvals` links open the inbox. - **Home.** A "Recently published" feed lists who published what, with links to the entries (`GET /activity`). - Content stores gain optional `history`, `readVersion` and `activity` methods. `DatabaseStore` and `GitHubStore` implement them, and `@usegraft/db` adds `publishedHistory`, `recentlyPublished` and `readPublishedRevision`. `compiler`, `db`, `studio` · [e8b55ac](https://github.com/AndersonDesign1/graft/commit/e8b55ac) - Studio's developer views follow the docs. Health leads with its numbers without boxes or a decorative chart, lists collections, recent compiles, approvals and branches as plain tables, and has a Compile button again, so out-of-date content is fixed where Studio shows it. Schema shows types as plain identifiers and "Required" as a word instead of a pill, and the sign-in page uses the `graft. / Studio` wordmark. States read "Out of date", "Never compiled" and "File missing" in place of "Drifted", "Not indexed" and "Orphaned". `studio` · [21b232a](https://github.com/AndersonDesign1/graft/commit/21b232a) - Studio's editor screens follow the docs. Home opens with what is waiting for you (drafts, an agent waiting on an approval, a pull request in review) and a plain table of collections, in place of the printed-paper front page. The collection list marks drafts with a dot and a word instead of a pill. The entry page sits in the docs' column at the docs' reading size, with no card around it. One word for one state: an edit not yet committed or published is a Draft everywhere, and every sentence uses the workspace's own verb, so a Studio on a laptop says "commit" and never "publish". `studio` · [fe7d03b](https://github.com/AndersonDesign1/graft/commit/fe7d03b) - Studio's frame and popups now match the docs. A 56px top bar runs across the page with the `graft. / Studio` wordmark and the branch on the left, search centred, and Commit (or Publish) and the account menu on the right. The sidebar sits under it on the page colour. Below 768px the sidebar opens from a menu button. Popups share one material: the elevated surface and a 10px radius, opening in 160ms and closing in 100ms. Dialogs and sheets add a dimmed backdrop with no blur and a visible close button; the command palette opens instantly and closes on Escape or a choice. Deleting or discarding asks in a confirm dialog that starts on Cancel and names the action ("Delete post") in place of the browser's `confirm()`. The command palette shows its keys, menus carry icons, toasts come one at a time, and committing ends with a plain check instead of an animation. `@usegraft/tokens` adds `--scrim`, the colour behind a dialog or sheet. `studio`, `tokens` · [b146045](https://github.com/AndersonDesign1/graft/commit/b146045) - Studio opens an entry as panes. The collection's list stays beside the document, with the open entry marked, so the next entry is one click away and the arrow keys move through the list. A new inspector beside the document shows the entry's status, where it is saved, its file and version, and what changed; ⌘\ or Ctrl+\ hides it for a full-width page. Under 1280px it opens over the document instead, and the list gives way under 1024px. Every pane header is the same height, and all of Studio's spacing now comes from the `--space-*` scale. `studio` · [cafd545](https://github.com/AndersonDesign1/graft/commit/cafd545) - The light scheme moves from ivory to chalk paper. `--paper-0` is now `oklch(0.992 0.002 95)` (about `#fdfcfb`) and `--paper-2` is `oklch(0.966 0.003 95)` (about `#f4f4f1`), on the same warm hue as the ink, so light mode reads white without going cold. Light borders are lighter to match (`--border` 0.11, `--border-strong` 0.24). Dark mode does not change. Anything that reads `--paper`, `--background` or `--border`, including Studio's light preview, picks this up with no code change. New type-role tokens set size, leading and tracking together: `--type-title`, `--type-section`, `--type-subsection`, `--type-lede` and `--type-meta`, each with its `--leading-*` and, for headings, `--tracking-*`. `--weight-regular`, `--weight-medium` and `--weight-semibold` name the only three weights in use. `--type-wordmark` and `--tracking-wordmark` set the docs top-bar "graft." wordmark. The landing page keeps its own wordmark for now. `tokens` · [f8c14f4](https://github.com/AndersonDesign1/graft/commit/f8c14f4) ### Fixes - Opening the account, sort or filter menu no longer turns Studio into a blank page. Each menu put its label outside a group, which the menu library refuses, and nothing caught the error. Menu labels now always come with their group, menu items sit on one line, and a rendering error shows what went wrong with a Reload button instead of a white page. `studio` · [d6aec19](https://github.com/AndersonDesign1/graft/commit/d6aec19) ## 1.0.0-beta.6 Released October 11, 2026. ### Fixes - One loopback list decides every "https unless loopback" question: `@usegraft/compiler` exports `LOOPBACK_HOSTS` and `isLoopbackHost`, and the revalidate webhook, Studio's `Secure` session cookie and `graft studio invite` all use them, so the invite check and the cookie can never disagree about a host. Paths under a regular file now read as absent instead of failing with `ENOTDIR`, and deleting a document that is already gone succeeds. `cli`, `compiler`, `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - `graft studio invite` refuses a plain `http://` address unless it is `localhost`, `127.0.0.1` or `[::1]`. The link carries a sign-in token, so plain http sent it unencrypted, and the session cookie is `Secure` off loopback, so the sign-in failed anyway. The usage message for a missing email now lists `--name` and `--days` too. `cli` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - Two error-handling fixes. `GRAFT_REVALIDATE_URL` with a `user:password@` part is refused at startup without repeating the credentials, and a network error that quotes the webhook URL has its query stripped, so a token in the URL no longer reaches logs, agents or Studio toasts. Creating a GitHub draft branch now reads only GitHub's "already exists" answer as a race: any other 422, such as a missing commit, fails with `REMOTE_STORE_FAILED` and GitHub's reason, where it used to retry to exhaustion and report the document busy. `compiler` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - Paths inside the content directory now refuse a dangling symbolic link. The check followed the link, so a link whose target did not exist read as absent, and a Studio or MCP write then created the target outside the content directory. A delete on a read-only filesystem now fails with `CONTENT_TREE_READ_ONLY`, the same error a save gives, instead of a raw `EROFS`. `compiler` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - A save to a GitHub draft can no longer be lost while the draft is being emptied. Publishing everything or discarding the last change used to read the draft branch and then delete it, and a save that landed in between was deleted with it after reporting success. The draft is now emptied with a fast-forward that fails if the branch moved, so the branch stays, holding no changes, and the next save reuses it. Invite-link editors get new draft branch names. Their key joins the hash with `--`, which a GitHub login cannot produce, so a login shaped like an email's key (`ana-lima-shop-test-0123456789`) no longer shares that person's draft. A draft saved under the old name before upgrading stays on GitHub as a branch: publish or discard it first, or merge it by hand. `compiler`, `mcp`, `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - `GITHUB_STORE_ENV` now lists the host commit variables the store reads (`VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, `RAILWAY_GIT_COMMIT_SHA`, `COMMIT_REF`, `GITHUB_SHA`), exported on their own as `DEPLOYED_SHA_ENV`. A server that allowlisted its environment from the old list dropped them, so Studio could not tell which commit was deployed. `compiler` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - Field descriptors now match what the validator checks. A pattern's flags are kept in the new `constraints.patternFlags`, so Studio checks `/^[a-z]+$/i` case-insensitively like the server does instead of flagging valid values. Constraints are listed only for the field types that enforce them: `field.string({ min: 10 })` validates nothing, so `describe_schema` no longer advertises a minimum for it. `contracts`, `core`, `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - `explain_error` for `REMOTE_STORE_FAILED` no longer promises that a failed save did not happen. A refusal means that one request was not applied, but a publish can move production before a later step is refused, and a network error can arrive after GitHub applied the request, so agents are told to check the branches before retrying. A 404 now also names its other cause: a private repository the token or GitHub App cannot see. `mcp` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - A hosted Studio's change diff keeps its memory bounded. It kept a copy of the whole search frontier for every changed line, so a large rewrite (two 10,000-line versions with nothing in common) needed gigabytes and could take the server down. It now skips the unchanged start and end, keeps only the part of the frontier each step uses, and past 2,000 changed lines falls back to every old line removed and every new line added. As before, the drawer renders at most 600 lines and marks a longer diff as truncated. A hunk whose old or new side is empty now starts at the line before it, as git does (`-0,0` for a new file). `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - `@usegraft/studio` also exports `ContentChangeNotice` and `ReviewRequest`, the two types its editor DTOs refer to, so typing a response needs one import instead of a second one from `@usegraft/compiler`. `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) - Studio fixes from review: - Discarding a local file that was staged as new and then deleted works, including in a repository with no commits yet. It used to try to restore it from HEAD, where it never was. - Local drafts report each changed file's version, as GitHub drafts do. Only a deletion has none. - A failed GitHub sign-in returns to the page the person started from, branch and hash kept, instead of Studio's front page. That needs the sign-in's own state cookie: a callback without a valid one still lands on the front page. - Slugs drop every combining mark, not only those in U+0300 to U+036F, so a title with one no longer gains a hyphen mid-word. - "News", "series", "species", "media" and "data" keep their form in the singular ("New news" became "New new"). - `ContentTreePanel` follows a change to its `branch` prop. It kept showing the first branch. - The first render after a view starts loading shows the loading state, not an empty page. - The vermilion scale uses the same lightness steps as the other hues, so unpublished-state colour reads at the same brightness. - `WorkspaceDto.sessions` is documented as what it is: whether the request carried a session cookie. `studio` · [2386e74](https://github.com/AndersonDesign1/graft/commit/2386e74) ## 1.0.0-beta.5 Released October 10, 2026. ### Breaking changes - **Breaking:** `graft serve --studio` no longer serves `GET` and `PUT /api/studio/v1/document`. Use `GET` and `PUT /api/studio/v1/entry`, which read and save the same files and carry a version for conflict detection. See Upgrading. `cli` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - **Breaking:** remove `GET` and `PUT /api/studio/v1/document` and the `DocumentDto` type. Use `GET` and `PUT /api/studio/v1/entry`, which read and save the same files, carry a version for conflict detection, and work against a GitHub store too. An entry deleted in a draft now stays in `/entries` as `status: "deleted"`, and `GET /entry` returns its published copy so it can be read or restored. `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) ### Features - MCP writes through the content store. On a `graft serve` that writes to GitHub (`GRAFT_GITHUB_REPO` set), `write_content` and `delete_content` commit to the connection's own draft branch instead of the read-only files, and three tools appear: `list_drafts`, `publish_drafts` (a pull request, or a commit to the production branch with the new `content:publish` scope) and `discard_drafts`. Agents and Studio editors share one draft model. `cli`, `mcp` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - Studio has an editor API on top of the content store. `GET /api/studio/v1/entries` returns a page of a collection searched, filtered, sorted and faceted on the server, from a listing that re-parses only files whose size or mtime changed, so a catalog of thousands stays fast. `GET/PUT/POST/DELETE /api/studio/v1/entry` and `POST /api/studio/v1/entry/duplicate` read, save, create (slug from the title), delete and copy entries; a save that sends the version it read as `baseVersion` is refused with `CONTENT_CONFLICT` when that version is stale (a save without one is unconditional). `/api/studio/v1/drafts`, `/drafts/diff`, `/drafts/publish` and `/drafts/discard` list unpublished changes, diff them, publish them and throw them away: a commit in your checkout locally (you push it yourself), a commit on the production branch or a pull request on GitHub. `GET /api/studio/v1/workspace` says where saves land and what Publish does for the caller. `graft serve --studio` writes through GitHub when `GRAFT_GITHUB_REPO` is set along with a credential (`GRAFT_GITHUB_TOKEN`, or a GitHub App). Locally, every save, create, delete and discard compiles, then calls `onContentChange` when one is configured and the content changed, so the app refreshes the way it does for the old routes; a failed refresh comes back as `refresh: { ok: false }` and Studio warns. `cli`, `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - A hosted Studio now has sign-in. Set `GRAFT_STUDIO_SECRET` and people sign in with GitHub (`GRAFT_GITHUB_CLIENT_ID` and `GRAFT_GITHUB_CLIENT_SECRET`) or with an invite link from `graft studio invite <email> --role editor`, into a signed, HTTP-only session cookie. `GRAFT_STUDIO_EDITORS` lists who may sign in with GitHub and with which role; without it, anyone with write access to the content repository can. An invite link signs its holder in with the role it was made with, whether or not they are on that list. Roles map to scopes: `viewer`, `contributor` (drafts, publishing opens a pull request), `editor` (adds the new `studio:publish` scope) and `admin` (adds `approvals:decide`). Bearer tokens keep working beside sessions. `cli`, `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - Add the content store: where authored content is read from and written to, behind one interface. `FilesystemStore` is today's behaviour. `GitHubStore` writes through the GitHub REST API, so a hosted surface on a read-only filesystem can save: each editor drafts on their own branch, and publishing lands one commit on the production branch authored by the editor, or opens a pull request. Every write can carry the version it was read at and is refused with the new `CONTENT_CONFLICT` code when the document changed underneath it, which covers a second tab or an agent writing under the same identity. Editors draft apart, so between two editors the guard is at publish: it refuses to overwrite a document production changed since the draft began until someone chooses to keep the draft or take the published version. Credentials are a fine-grained token (`GRAFT_GITHUB_TOKEN`) or a GitHub App (`GRAFT_GITHUB_APP_ID`, `GRAFT_GITHUB_APP_PRIVATE_KEY`); `githubStoreFromEnv` reads the configuration. GitHub failures surface as `REMOTE_STORE_FAILED`: a refusal carries GitHub's own message and status, and an unreachable GitHub says so. `@usegraft/compiler/testing` exports an in-memory GitHub for tests. `compiler`, `contracts`, `mcp` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - Fields can say how an editor should see them. `field.select({ options })` holds one value from a fixed list, `field.reference({ to })` holds the slug of a document in another collection, every field takes a `label`, and `field.number({ format: "money" })` declares an amount in the smallest currency unit. `describe_schema` now includes each field's label, limits (`min`, `max`, `int`, `maxLength`, `pattern`, `maxItems`), options and reference target, so Studio and agents see the rules the validator applies. `contracts`, `core` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - Studio is rebuilt for editors. Content comes first: a home page with what is waiting to publish and the collections as a table of contents, a table per collection searched, filtered, sorted and paged on the server with bulk publish, discard and delete, and an entry editor that shows the title in the site's display face, every field with its label, limits and help, and the content in the rich editor. Fields edit in place, including choices, money, references with a search picker, tags, image galleries, nested groups and repeatable lists such as product variants; "Edit in Raw MDX" is gone as the only way to change them. Problems are reported on the field in plain words before a save, and a change made elsewhere since the entry was opened is reported instead of overwritten. Publishing happens in one sheet that names what will happen (publish, submit for review, or commit), lists the changes as entries with their changed fields ("Price: $139.00 → $145.50"), resolves conflicts, and discards. New entries ask only for required fields. A hosted Studio shows a sign-in screen. Index health, schema, history, branches, approvals and settings move under Developer. `ContentTreePanel` now renders the full editor. `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) ### Fixes - `graft serve` passes every `Set-Cookie` header through. Its Node adapter copied response headers one value per name, so a response setting two cookies arrived with one, and a hosted Studio's sign-in lost the session cookie. `cli` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - Committing a staged rename from Studio works: the old path is recorded as removed instead of passed to `git add`, which refused it because the file is gone. `studio` · [ab7cca3](https://github.com/AndersonDesign1/graft/commit/ab7cca3) - `@usegraft/studio` exports the types of the editor API: `EntryDto` (the replacement for the removed `DocumentDto`), `EntryList`, `EntrySummary`, `SaveEntryResult`, `WorkspaceDto`, `DraftsDto`, `DraftDiffDto`, `PublishResultDto` and the types they use. Code that calls `/api/studio/v1/entry` can type its responses without copying them. `studio` · [3b396c0](https://github.com/AndersonDesign1/graft/commit/3b396c0) ## 1.0.0-beta.4 Released October 9, 2026. ### Features - Graft now tells your app when content changes, so its cache refreshes without a manual call. Before this, an agent's `write_content` or a Studio save updated the index and returned the ChangeSet to the caller. The app kept serving its cached pages until someone called its revalidate route by hand. **Webhook.** Set `GRAFT_REVALIDATE_URL` and `GRAFT_WEBHOOK_SECRET` where `graft serve`, `graft studio`, `graft mcp`, `graft merge` or `graft migrate` runs. After each local write that changes the index, Graft POSTs `{ branch, gitSha, changes }` with `Authorization: Bearer <secret>`, the body the documented revalidate route already reads. The URL must use https, except on loopback (`localhost`, `127.0.0.1`, `[::1]`), and redirects are refused. A URL without a secret stops the command before it connects to anything. `graft compile` does not call the route, because a deploy compiles before the new version is live. A write to a GitHub-backed store is a draft and calls nothing: the app refreshes when a publish reaches the production branch and the host rebuilds. **Hook.** `createGraftMcp`, `createGraftMcpHandler`, `createStudioApiHandler` and `createStudioHandler` take `onContentChange(event)`. An app that mounts the MCP endpoint itself refreshes in-process: ```ts createGraftMcpHandler({ // … onContentChange: ({ branch, changes }) => { revalidateContent(branch, changes); }, }); ``` `@usegraft/compiler` exports the pieces: `notifyContentChange`, `createRevalidateWebhook`, `revalidateWebhookFromEnv` and the `ContentChangeEvent` / `ContentChangeListener` types. **A failed refresh never fails the write.** The write already landed. The server logs the new `REVALIDATE_FAILED` error, `write_content` and `delete_content` return `refresh: { ok: false, error, message, fix }`, and Studio shows a warning toast. Writes that change nothing do not call the listener. Also fixed: saving a document with an empty body a second time appended a newline to the file, so it projected as a change. `composeDocument` now leaves a body-less document byte-identical. `cli`, `compiler`, `contracts`, `mcp`, `studio` · [4831a77](https://github.com/AndersonDesign1/graft/commit/4831a77) ## 1.0.0-beta.3 Released October 8, 2026. ### Breaking changes - `graft asset put` refuses a key that already holds a file, like `put_asset`. The MCP `put_asset` tool has refused a taken key with `ASSET_EXISTS` unless the caller passed `overwrite: true`. The CLI did not check: it replaced whatever was stored under the key. The store keeps no version history, so the old file was gone, and every page pointing at the key showed the new one. The CLI now checks first and fails with the same `ASSET_EXISTS` error. Pass `--overwrite` to replace the file on purpose. Both also make the upload itself conditional. `Storage.put` from `@usegraft/assets` takes `{ ifAbsent: true }`, which sends `If-None-Match: *`, and throws `AssetKeyTakenError` when the store answers 412. So the store refuses a key that a concurrent upload took between the check and the write, or that a credential allowed to upload but not read cannot see. A store that ignores the header writes as before, behind the existence check. **Breaking:** a script that re-uploads to the same key now fails until it adds `--overwrite` or picks a new key. `assets`, `cli`, `mcp` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957) - `@usegraft/sdk-next` now works on Next.js 15 as well as 16, and needs a release with the 2026 security fixes. The peer ranges are `next@^15.5.24 || ^16.3.3` and `react@^19.0.0`. CI builds and serves a real app on each major, type-checks the Next.js guide's examples against it, and checks reads, MDX, targeted refresh and `updateContent` in a real Server Action. The package used to declare Next.js 15 or newer while calling APIs that only Next.js 16 has. What changed for you: - **Breaking:** the peer range now starts at 15.5.24 and 16.3.3, the first releases that fix the critical Next.js advisories published in 2026. Upgrade Next.js if you are on an older 15.x. Next.js 14 is not supported: it no longer gets security fixes, and those advisories are still open on it. - `createGraft`, `MdxBody` and `revalidateContent` work on 15 and 16. On Next.js 15, tag your reads with `unstable_cache(fn, keys, { tags: tagsFor(...) })`. The Next.js guide shows both paths. - `updateContent` still needs Next.js 16. On 15 it now throws a `GraftError` with code `FRAMEWORK_VERSION_UNSUPPORTED` whose `fix` names `revalidateContent`. Before, the package imported `updateTag` by name, and Next.js 15 does not export it. - `@usegraft/sdk-next/config`, `@usegraft/sdk-core/db` and `@usegraft/studio/panels` now resolve their types under `moduleResolution: "node"`, the setting Next.js 15 writes into a new tsconfig. Before, those imports had no types there, and reads came back typed as `unknown`. `FRAMEWORK_VERSION_UNSUPPORTED` is a new error code, with an entry in the error reference and in `explain_error`. `contracts`, `mcp`, `sdk-core`, `sdk-next`, `studio` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c) ### Features - Add `graft compile --json`. It prints one JSON object on stdout and nothing else: `{ "branch", "gitSha", "changes" }`, where `changes` is the compile's ChangeSet. The summary line and any other log output go to stderr. It works on the static and Postgres engines. Until now the only ways to get a ChangeSet were an agent's `write_content` result or calling `compile()` from `@usegraft/compiler`. The new output is the body a revalidate route reads, so a deploy script can pipe it on: ```sh graft compile --json | curl --fail-with-body -X POST -H "Authorization: Bearer $GRAFT_WEBHOOK_SECRET" -H "content-type: application/json" --data-binary @- https://example.com/api/revalidate ``` `--fail-with-body` makes curl exit non-zero on an error response while still printing its message and fix. It needs curl 7.76.0 or newer. On an older curl, use `--fail`, which fails the same way but drops the body. postgres-js prints server notices with `console.log`, so while a `--json` compile runs, `console.log` is routed to stderr. Nothing a dependency logs can corrupt the JSON. `cli` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c) - `graft serve` can check the token audience and record trusted callers as people. `GRAFT_TRUSTED_ISSUERS` built each issuer with only its URL. The token audience was never checked, so a token the provider minted for another of your apps was accepted, and every caller was recorded in the audit log as an agent. Two variables now apply to every listed issuer: - `GRAFT_TRUSTED_AUDIENCE`: the accepted `aud` values, comma-separated. When issuers are set without it, `graft serve` prints a warning at startup. - `GRAFT_TRUSTED_ACTOR_KIND`: `agent` (the default) or `human`. Any other value stops the server from starting with `INPUT_VALIDATION_FAILED`, before it opens a database connection. `cli` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957) - Concurrent `graft migrate --apply` runs apply each migration once. The ledger was read before a run and written after it, so two runs started together both saw a migration as pending and both applied it. The second then failed on the ledger's unique index, after its content rewrite and data updates had already run. `@usegraft/db` exports `withMigrationLock(db, branchId, work)`. It holds a transaction-scoped Postgres advisory lock for the branch while `work` runs. `graft migrate --apply` reads the ledger only once it holds the lock, so a second run waits for the first, then skips what the first applied. `graft merge --apply` takes the same lock on both branches, in a fixed order: it writes the target's ledger, and reads the source's, so no migration can land on the source while its rows move. The lock is transaction-scoped so it works behind a transaction-mode pooler such as Neon's PgBouncer. A crashed run drops its connection, which ends the transaction and frees the lock. There is no stale claim to clear. The lock's transaction pings every 10 seconds while the run works, so an idle-in-transaction timeout longer than that does not end it early. It needs a pool of at least two connections, one for the lock and one for the work, and refuses a smaller pool up front instead of waiting on itself. `cli`, `db` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957) ### Fixes - `graft <command> --help` prints that command's usage and exits 0. Before, `--help` or `-h` after a command name failed with `unknown option "--help"`. Every command now has its own usage block: the usage line, what it does, and only the flags it reads. Help is answered before the other arguments are parsed, so `graft merge --into --help` still prints the merge usage. An unknown command with `--help` still reports the unknown command. `cli` · [56c0957](https://github.com/AndersonDesign1/graft/commit/56c0957) - Clearer wording for two error explanations. `NEEDS_DATABASE` now says the project runs on the static storage engine and the feature needs the Postgres engine, instead of "static index mode" and "Postgres-tier". The registry entry now says "core registry" instead of "Tier-1 registry". `@usegraft/tokens` adds a `--measure` token for the reading width of prose. It also defines `--space-5`, which eight declarations already used while it was undefined, so callouts and other spaced elements lost their padding. Callouts get their own note and warning hues instead of reusing the accent, and `--n-10` names pure white for text on the primary color in dark mode. `mcp`, `tokens` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c) - `run_function` now sets `openWorldHint: true`. It runs the project's own functions, and a function can call any external service, so it cannot promise the closed world every other tool does. Every other tool keeps `openWorldHint: false`. `mcp` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c) - Fix the caching note in each adapter's JSDoc. It said to purge `tagsForChanges(branch, changeSet)` "from your compile webhook", but Graft sends no webhook. It now says to purge from your own route, called with a compile's change list, for example the output of `graft compile --json`. `sdk-astro`, `sdk-react-router`, `sdk-sveltekit`, `sdk-tanstack-start` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c) - Keep every cache tag within 256 characters. Next.js skips a longer tag with a warning instead of an error, and a skipped tag is never invalidated, so a page read under a long slug stayed stale after its content changed. `documentTag` and `collectionTag` now return a tag longer than 256 characters as its first part plus a digest of the whole. Reads and `tagsForChanges` build tags the same way, so they still match. Tags of 256 characters or fewer are unchanged. `sdk-core` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c) - `useContentSearch` and `graft.searchContent` now treat an empty or whitespace-only query as a search with no results. They return `[]` without sending a request, so a search box that starts empty no longer shows an error before anyone types. All three hooks also stop showing the previous answer for one render when their arguments change. `useContent` with a new slug, or `useContentSearch` with a new query, now reports loading in that same render instead of briefly rendering the old document or hits. `sdk-react` · [f92b97c](https://github.com/AndersonDesign1/graft/commit/f92b97c) ## 1.0.0-beta.2 Released September 7, 2026. ### Fixes - Clear leftover CodeQL findings: no ReDoS regex on storage URLs, no exception text on `/healthz` or Studio asset-url failures, and `graft studio` only opens a validated loopback URL. `assets`, `cli`, `studio` · [98ff4ba](https://github.com/AndersonDesign1/graft/commit/98ff4ba) ## 1.0.0-beta.1 Released September 2, 2026. ### Fixes - Install commands drop `@beta`. A plain install is now the right install. Every README said `npm i @usegraft/<pkg>@beta`, because `latest` pointed at `0.2.0` while the docs described `1.0.0-beta.x`. Writing the tag into 22 files treated the symptom. The defect was the dist-tag: `latest` is what a bare install resolves, and it resolved to somewhere nobody should land. `latest` now points at the prerelease across all 21 published packages, and the `0.x` line is deprecated, so the tag has nothing left to do. `install-tag.mjs` inverts with it — it strips tags instead of adding them, and CI fails if one comes back. The half a script cannot check is the registry. Publishing a prerelease while `latest` sits on something older reopens the original bug and nothing in the repo will notice. That property is kept by moving the tag at release, and it is written down in the script rather than assumed. `assets`, `auth`, `cli`, `compiler`, `content-api`, `content-migrations`, `contracts`, `core`, `db`, `mcp`, `mdx-safety`, `registry`, `sdk-astro`, `sdk-core`, `sdk-next`, `sdk-react`, `sdk-react-router`, `sdk-sveltekit`, `sdk-tanstack-start`, `studio`, `tokens` · [52fc3e6](https://github.com/AndersonDesign1/graft/commit/52fc3e6) - Dependency floors raised to versions without known advisories. `main` was carrying 34 Dependabot alerts, and a range-respecting update across the workspace clears 23 of them: `next` 16.2.9 → 16.3.3 (nine alerts on its own), `hono` 4.12.27 → 4.13.5, `postcss` 8.4.31 → 8.5.23, plus `astro`, `tar`, `sharp`, `svgo`, `@astrojs/vercel` and `@hono/node-server`. Declared ranges move with the lockfile, which is what makes this a change worth a version rather than a lockfile refresh: `zod` `^4.1.0` → `^4.5.4`, `jose` `^6.0.0` → `^6.2.10`, `jiti` `^2.4.0` → `^2.7.0` and the React type packages. Every one is a floor raised inside the same major. **No peer range changed** — `sdk-next` still peers `next >=15.0.0`, `sdk-react` still peers `react >=18.0.0` — so nothing a consumer already resolves stops resolving. Eleven alerts survive this and are deliberately not addressed here. Two are `vitest`, pinned at `^2.1.9` against a fix in `3.2.6`: a major upgrade of the test framework is its own change, not a line in a dependency bump. The other nine are transitive versions their parents pin — `esbuild` now resolves `0.28.1` and `0.28.2` alongside `0.18.20`, `0.21.5`, `0.25.12` and `0.27.7`, and only `pnpm.overrides` dislodges those. Each override is a compatibility bet and belongs where it can be argued one at a time. `auth`, `cli`, `contracts`, `core`, `mcp`, `registry`, `sdk-next`, `sdk-react`, `studio` · [27b8468](https://github.com/AndersonDesign1/graft/commit/27b8468) - Point the getting-started install at `@beta`, because the docs already describe it. `latest` is `0.2.0` and these docs are written against `1.0.0-beta.x`. That gap is not cosmetic: `approvalPolicy` moved out of `GRAFT_APPROVAL_POLICY` and into `graft.config.ts`, so someone following the page on a default install configures a policy their build ignores. The relative-endpoint fix and the database leaving the browser dependency graph are also beta-only. Every install command on the page now carries `@beta`, with a note saying it is a channel rather than a snapshot — subscribe once, upgrade normally, drop the tag when 1.0 ships. `cli` · [1f23844](https://github.com/AndersonDesign1/graft/commit/1f23844) - Move the docs site to the static tier. It needs no database now. The docs are MDX in git. The only things forcing Postgres were a `submissions` collection and two functions — `docStats` and `submitContact` — that **no page in `src/` referenced**. They were declared to exercise the Postgres tier and never rendered, while the landing page carries that demo properly: its own `submitContact`, wired to a `<ContactForm />` a visitor can actually post. So documentation was paying a database's availability, cost and cold starts to demonstrate something it did not show. `graft compile` writes `.graft/index.db` and 36 pages prerender from it. `/mcp` still serves agents — the docs MCP handler takes `staticIndexPath`, so it reads the same artifact. `/api/search` stays on-demand for the same reason. The two routes that genuinely needed Postgres, `/api/fn/[name]` and the authenticated `/api/mcp`, are gone; both were already local-only, the latter by its own comment ("never set it on a deployed instance"). The build is `graft compile && astro build`, and the Vercel adapter is told to include the artifact — Vercel traces imports, not data files, so the on-demand routes would otherwise deploy without the thing they read. `cli` · [db5abe6](https://github.com/AndersonDesign1/graft/commit/db5abe6) - Mark a grouped release as a prerelease when the version has one. `v1.0.0-beta.0` was created unmarked, which leaves GitHub free to show it as the repository's Latest release — telling every visitor the beta is the current version, the exact confusion a beta channel exists to prevent. changesets marks its per-package releases; the grouped one now does too. `cli` · [411e9f0](https://github.com/AndersonDesign1/graft/commit/411e9f0) - Keep every documented install command on the channel we actually ship. While the repo is in beta, `latest` is an older version than the docs describe. A README saying `npm i @usegraft/cli` sends a reader to a build that does not match the page they read it from — during this beta that means `approvalPolicy` in `graft.config.ts` silently doing nothing, because `0.2.0` still reads an environment variable. `scripts/install-tag.mjs` derives the tag from `.changeset/pre.json` instead of anyone remembering: pre mode means `@beta`, stable means no tag. `release:beta-enter` and `release:beta-exit` both run it, so entering and leaving the channel rewrites 22 READMEs and the docs with it, in both directions. CI checks it, because the failure is silent otherwise. CHANGELOGs are deliberately untouched. They narrate what happened at a version — "`npm i @usegraft/sdk-react` installed postgres and drizzle-orm" is a statement about the past, and rewriting it would falsify the record. `cli` · [0e537ee](https://github.com/AndersonDesign1/graft/commit/0e537ee) - `npx @usegraft/cli init` in the README could run a copy from weeks ago. `npx`, `pnpm dlx` and `bunx` fetch a package, run it, discard the process — and keep the tarball. So a bare runner command re-runs whatever it cached last, without saying so. That is the failure the dist-tag work was meant to close, arriving by another road: the registry is right and the machine is stale. `@latest` is what tells them to check, and it is why `npx create-next-app@latest` is written that way everywhere. The reason is the cache, not decoration. `npm i -D @usegraft/cli` stays bare. A bare install already resolves `latest` — that is what it means — so the tag would add a word that changes nothing, and a tag that is load-bearing on some lines and noise on others teaches a reader to skim past tags where it matters. `scripts/install-tag.mjs` now enforces both halves: `@latest` on the ephemeral three, absent on the installers. `cli` · [94c8909](https://github.com/AndersonDesign1/graft/commit/94c8909) ## 1.0.0-beta.0 Released September 1, 2026. ### Breaking changes - **BREAKING:** approval policy moves from `GRAFT_APPROVAL_POLICY` to `approvalPolicy` in `graft.config.ts`. The env var is ignored, and `graft serve` warns once at boot if it is still set. `createFunctionsHandler` has documented this setting as config-owned all along: "deliberately a value the operator writes in config rather than an env var, because turning off the gate on irreversible work should appear in a diff and a review." The CLI was the piece still reading an env var, so the rationale was written down and not enforced. This is the setting that lets `deleteRecord` hard-delete rows with no human in the loop, and a hosting dashboard is where that decision goes unreviewed. It is parsed like `mdxTrust`: an unknown value is refused rather than defaulted, so a typo cannot silently pick a weaker policy. ```ts // graft.config.ts export const approvalPolicy = "unattended"; ``` **An approval presented to an ungated call is now spent.** Under `"unattended"` the gate is skipped, and the whole block went with it, including `approvals.consume`. A granted row stayed `approved` and replayable: tighten the policy later and that row still authorized a destructive call nobody re-reviewed. One-shot has to survive a policy change, which is exactly when the stale row is dangerous. Consuming is best-effort here, because an ungated call must not fail on the approval store. **`run_function` over MCP gets the same rate-limit backstop as `POST /api/fn`.** `graft serve` passed `{ limit: 60, windowSeconds: 60 }` to the functions handler and nothing to the MCP handler, so a function with no per-function `rateLimit` was capped on one transport and uncapped on the other. `tools/functions.ts` claims both surfaces apply rate limits identically; now they do. **`assertSafeMdx` reports unparseable MDX as `INPUT_VALIDATION_FAILED`.** `UncheckableMdxError` escaped it raw, so `write_content` and a Studio save returned a bare `Error` where every other rejection on that path is a structured `GraftError` — a client could not tell malformed input from a transport fault. `graft compile` catches the raw error itself and is unaffected. All four found by cubic on the pull request. `cli`, `core`, `mdx-safety` · [2561b47](https://github.com/AndersonDesign1/graft/commit/2561b47) - **BREAKING:** `writeDocumentFile(root, sourcePath, raw)` replaces `writeDocumentFile(fullPath, raw)`, and contains the path itself. It returns the resolved path. `.greptile/rules.md` told reviewers that "every filesystem sink in `@usegraft/compiler` performs path containment with symlink refusal". That was false. `writeDocumentFile` took an already-resolved path and wrote it. Studio resolved carefully before calling — its source even carries a comment about `"../../../../tmp/pwn"` arriving as a clean-looking `"pwn"` — while MCP's `write_content` did not, which is how a traversal shipped in a package whose own review rules said it could not. Containment that lives in each caller is not an invariant, because the next caller does not inherit it. The sink no longer trusts its input, so the rule is now true rather than aspirational. The rule text was corrected too, and says what went wrong, since a rule that has been false once is worth annotating. Raised by cubic on the pull request, against the rules file rather than the code. `compiler`, `mcp`, `studio` · [a6b7ddf](https://github.com/AndersonDesign1/graft/commit/a6b7ddf) - **BREAKING:** `createClient` takes `index` and no longer takes `db`. Pass a Postgres handle to `createDbClient` from the new `@usegraft/sdk-core/db` entry point instead. Every framework adapter's `createGraft` still takes either shape, so app code that used an adapter is unaffected. `npm i @usegraft/sdk-react` installed `postgres` and `drizzle-orm`. cubic flagged it on the pull request and it was worse than a stray manifest entry: `sdk-core/src/client.ts` imported `createDbIndexReader` from `@usegraft/db` for its **value**, so the database package was reachable code, not a tree-shakeable type import — in a package whose stated premise is that a database handle never reaches the browser. The README and `/docs/sdk-reference` both made that claim while the dependency graph contradicted it. The runtime edge now lives in `@usegraft/sdk-core/db`, and `@usegraft/db` is an **optional peer dependency** of `sdk-core`. A server adapter declares it outright and resolves it; a browser install never pulls it. The five server adapters gained it as a direct dependency, which is honest — they always did need it. **The type edge had to move too, or the fix would only have been half true.** `ClientOptions` referenced `Database`, and `ContentRow` was `typeof contentIndex.$inferSelect` — derived from a Drizzle table — so any package that merely wanted to _describe_ a row had to install a database driver to name the type. `ContentRow`, `ContentIndexReader`, `ReaderReadOptions`, `ReaderSearchOptions`, `ContentSearchHit` and `ChangeSet` now live in `@usegraft/contracts`, the layer every package already shares. `@usegraft/db` re-exports all six, so existing imports keep resolving, and its table now _proves_ it still matches the published contract instead of defining it — a compile-time assignment that fails if a column changes type. The dependency runs the right way round now: the seam owns the shape and the implementation conforms to it. `@usegraft/content-api` drops `@usegraft/db` entirely; its imports were always type-only. Two smaller fixes fall out of the same review. `@usegraft/sdk-react` now refuses a **per-read** `branch` on an endpoint-backed handle, not just one passed to the constructor: the content API pins its branch server-side and rejects the query param, so `getContent(c, s, { branch })` silently read main while the caller believed they were reading a preview. And the three read helpers are `async`, so that refusal arrives as a rejection rather than a synchronous throw from a function declared to return a promise — the kind a caller handling errors with `.catch()` never sees. An index-backed handle is unaffected, which is tested, because that is the one configuration where a branch is meaningful. `content-api`, `contracts`, `db`, `sdk-astro`, `sdk-core`, `sdk-next`, `sdk-react`, `sdk-react-router`, `sdk-sveltekit`, `sdk-tanstack-start` · [e2829b4](https://github.com/AndersonDesign1/graft/commit/e2829b4) - **BREAKING:** `@usegraft/db` is an optional peer dependency of `@usegraft/core` rather than a dependency. `db-out-of-the-browser` moved the database off sdk-core's direct dependencies, but only half the graph went with it. `@usegraft/core` stayed a hard dependency of sdk-core and kept its own hard dependency on `@usegraft/db`, so the chain survived one hop further out: sdk-react -> sdk-core -> core -> db -> postgres, drizzle-orm Bundling was already safe, because sdk-core imports core with `import type` and those erase. The install was not: `npm i @usegraft/sdk-react` still downloaded `postgres` and `drizzle-orm`, which is the exact complaint that fix opens with. cubic kept the thread open on the pull request and was right to. `npm i @usegraft/sdk-react` now pulls one external package, `zod`. Every package that reaches core's database-backed modules at runtime already declares `@usegraft/db` directly (`cli`, `compiler`, `mcp`, `studio`, and the five server adapters), so nothing needs a new dependency. The packages that depend on core without it — `auth`, `content-migrations`, `sdk-react` — import only types. If you depend on `@usegraft/core` directly and call `defineDataMigration`, `records`, or the functions handler, add `@usegraft/db` to your own dependencies. `core`, `sdk-core`, `sdk-react` · [655e4d1](https://github.com/AndersonDesign1/graft/commit/655e4d1) ### Features - Let the content API be read from a browser on another origin. `@usegraft/sdk-react` reads over HTTP from a browser, and the handler sent no CORS headers at all — so unless the app and the content API shared an origin, every read was blocked by the browser. That is the ordinary deployment for the browser client, which made it largely unusable as shipped. cubic raised it on the pull request. `createContentApiHandler` takes `allowedOrigins`: a list, or `"*"`. Omitted means no CORS headers and same-origin only, which stays the default because publishing an endpoint to other origins is the deployer's decision rather than a library's. `graft serve` reads `GRAFT_CONTENT_ALLOWED_ORIGINS` (comma separated, or `*`). Details that are easy to get wrong and are tested: - An allowed origin is echoed with `Vary: Origin`. Without `Vary`, a shared cache can hand one origin the response it stored for another and the allowlist stops meaning anything. - A disallowed origin gets an ordinary response with no CORS header, not a refusal. The browser enforces it; answering differently would turn the allowlist into an origin oracle for non-browser callers, who are not bound by CORS in the first place. - `OPTIONS` preflight is answered before method validation and before the rate limiter, so a browser asking permission is neither a `405` nor a charge against the budget for the read it precedes. - Error responses carry the headers too, or the browser hides the body and a developer debugging a `400` sees an opaque network failure instead of the `fix` this API took care to send. `cli`, `content-api` · [d5f5567](https://github.com/AndersonDesign1/graft/commit/d5f5567) - Add `list_packages`, so an agent can answer "what do I install?" The tool surface could already introspect a project that exists — its collections, functions, errors, owned primitives. Nothing said which `@usegraft/*` package to reach for, so a user on SvelteKit could only be told about `@usegraft/sdk-sveltekit` if the model happened to have read the docs. That is the first question anyone asks, and it was the one question with no surface. Filter by `framework` and you get that adapter plus the packages every project needs, never a competing one. Filter by `tier` and a static project is never told to install something that cannot work without Postgres. It is registered on the public documentation mount too: it carries nothing about the project, only which of Graft's own packages exists, which is documentation in the plainest sense. `PACKAGE_KNOWLEDGE` is held in lockstep with what actually ships by a test, the same way `ERROR_KNOWLEDGE` is with `ErrorCodes` — a package added without an entry is a package no agent will ever suggest. It caught `@usegraft/tokens` missing on its first run. `cli`, `mcp` · [76baf51](https://github.com/AndersonDesign1/graft/commit/76baf51) - Let `endpoint` be a same-origin path, which is what the docs already tell you to write. `createGraft({ endpoint: "/api/content/v1" })` threw `TypeError: Invalid URL` before a single read. `normalizeEndpoint` called bare `new URL(endpoint)` with no base, and a relative path has no origin to parse against. That form is not a stray idea — it is the example in `/docs/reading-content` and in the `createGraft` JSDoc that ships inside `@usegraft/sdk-react`. So the first thing a browser reader copied out of the documentation crashed at construction. cubic raised it on the pull request. A string endpoint now resolves against `location.href` when one exists. An absolute endpoint is unchanged, and a `URL` instance is unchanged. Outside a browser there is no origin to resolve against, and guessing one would send content reads somewhere arbitrary, so a relative path there is refused as `CONFIG_INVALID` with that reason rather than a bare `TypeError`. `content-api`, `sdk-react` · [655e4d1](https://github.com/AndersonDesign1/graft/commit/655e4d1) ### Fixes - Rate-limit the content API, and give `graft serve` the same backstop there it already gave functions. `graft serve` passed `rateLimit: { limit: 60, windowSeconds: 60 }` to `createFunctionsHandler` and nothing to `createContentApiHandler`, which had no such option to pass. Greptile demonstrated the consequence on the pull request by running it: 61 requests to `/api/content/v1/documents`, 61 responses of `200`, no `Retry-After` on the last one. These routes authenticate nobody and run database listings and full-text searches, so one anonymous caller could keep the index busy for as long as it liked. `createContentApiHandler` now takes `rateLimit` and `trustedProxyHops`. Omitting `rateLimit` means unlimited, which stays correct for a handler mounted behind something that already limits. `graft serve` passes the same 60 per 60 seconds it gives functions. The counter is in memory rather than in the audit table. The function limiter counts rows it is already writing; these are reads that write nothing, and adding a write per read to enforce a read limit inverts the cost of the thing being protected. Two honest consequences, both documented: it is per process, so N replicas allow N times the limit and a restart resets it; and it is a fixed window where functions get a rolling one. A deployment needing an exact global limit puts it in the proxy already terminating TLS. The check runs before the index is touched, and after method and route validation. Both halves are tested: a limit that fires once the query has already run protects nothing, and charging a `405` to the bucket would let someone probing with the wrong method spend the shared anonymous budget. **The caller-identity rule is now shared rather than copied.** `rateIdentity` and the peer registry move from `@usegraft/core` to `@usegraft/contracts`, which both packages already depend on, and `@usegraft/core` re-exports them so `setRequestPeer` keeps resolving where callers already import it. That rule — never read `x-forwarded-for` unless the deployment declares its proxy depth, then count from the right — is listed in `.greptile/rules.md` as a security invariant precisely because the obvious implementation is the wrong one, and two copies of it is how a future fix lands in only one. Also documents what was true before this change and unwritten: the content API performs no authentication at all, so its `collections` list is a security boundary rather than a convenience, and `graft serve` registers every collection in the config. `cli`, `content-api`, `contracts`, `core` · [15568eb](https://github.com/AndersonDesign1/graft/commit/15568eb) - Add a versioned, read-only authored-content HTTP API and a remote `ContentIndexReader`. `graft serve` now mounts document reads and search at `/api/content/v1`, fixed to the server's resolved branch. `cli`, `content-api`, `mcp` · [1139a88](https://github.com/AndersonDesign1/graft/commit/1139a88) - Let a local MCP server ask its operator to decide a destructive call in-band, instead of failing with an id for them to run `graft approve` on. Opt-in and off by default: `approvalElicitation: { decider }` on `createGraftMcp`, or `graft mcp --elicit-approvals`, which records the decision as the OS user — exactly who `graft approve` records. A remote or public mount must never enable it: there is nobody at the other end to ask. What changes is how the human is reached. What does not change is anything underneath: the decision is still a row in `approvals`, still one-shot, still bound to the exact function and canonical input, still stamped with `decided_role = current_user`, and still refused by Postgres when the decider is the requester. `decider` is configured rather than taken from the connection precisely because that predicate lives in the UPDATE's own `WHERE`. A client that never declared the elicitation capability falls back to the id-and-retry flow. Dismissing the prompt leaves the approval pending — only an explicit no records a denial. `delete_content`, `run_function` and the DESTRUCTIVE_OP_REQUIRES_APPROVAL explanation all now say the in-band path exists, so an agent seeing either outcome knows both are ordinary. `cli`, `mcp` · [d3c51d1](https://github.com/AndersonDesign1/graft/commit/d3c51d1) - Add a real release channel switch, and one GitHub Release per version. Canary was doing a job it cannot do. A snapshot is `0.0.0-canary-<timestamp>` forever: it sorts _below_ every real release, npm never offers it as an upgrade, and there is no path from it to a stable version — you hand someone a build and they pin a timestamp. It answers "does this commit work?", not "is the next version ready?" Beta is changesets prerelease mode: `1.0.0-beta.0` → `1.0.0-beta.1` → `1.0.0`. A tester runs `npm i @usegraft/cli@beta` once and gets each new beta as an ordinary upgrade, while `latest` stays where it is. ```sh pnpm release:beta-enter # commit .changeset/pre.json pnpm release:beta-exit # graduate to stable pnpm release:channel # which channel am I on? ``` release.yml's own comment named the reason pre mode was avoided: the state is committed, feat/core takes every ordinary push, and nothing would remind you to leave. So the release job now prints the active channel before anything publishes, and the canary path refuses to run in pre mode and says why. Being in beta is something the log tells you, not something you remember. **One release per version.** `fixed` makes all 21 packages one product on one version line, but changesets/action tags and releases each separately — 21 tags and 21 GitHub Releases per version, with one arbitrarily flagged "Latest" because GitHub picks by recency. Tags fragmented the history instead of grouping it. A `v<version>` release now collects every package's changelog entry. The per-package tags stay, because npm and provenance point at them. `cli` · [76baf51](https://github.com/AndersonDesign1/graft/commit/76baf51) - CI asserts that `.github/rulesets/*.json` still matches the branch protection GitHub is actually enforcing. Those files are a record, not a mechanism — a rule relaxed in the web UI leaves no diff anywhere, which is why the directory exists and also its weakness. The README carried "checked 2026-08-31", a claim with a shelf life. `pnpm check:ruleset-drift` turns it into a job. It is deliberately one-directional: it never applies the file, because a commit that could rewrite branch protection is a commit that could remove it. The record can fail the build without being able to weaken the branch. Reading bypass actors needs `administration: read`, which the built-in `GITHUB_TOKEN` cannot hold — that permission is not one a workflow may request for it. The job reads a `RULESETS_TOKEN` secret (a fine-grained PAT scoped to this repository, `Administration: Read-only`). Until it exists the job skips and says why rather than passing: without `bypass_actors` GitHub answers `200` with `rules` alone, and checking the rules while never checking who can ignore them prints a green tick nobody should trust. A fork's pull request sees the same skip, so outside contributors are not blocked. It runs as its own job rather than inside `verify`, keeping that token away from the runner that executes every installed dependency. `cli` · [4ac881c](https://github.com/AndersonDesign1/graft/commit/4ac881c) - Fix the install command on every package page. `npx graft` resolves to an unrelated package on npm, so the documented way to run the CLI without installing fetched the wrong thing. It is `npx @usegraft/cli` everywhere now, with a note saying why the scoped name is needed. Fix the `graftRoute` example in `@usegraft/sdk-astro`. It showed a config object, but `graftRoute` takes the handler, so the snippet did not compile. Document the static index in the SDK READMEs. `createClient` and `createGraft` both accept `index` from `openStaticIndex(".graft/index.db")`, which is what `graft init` scaffolds by default, and none of the READMEs mentioned it. Every read example now shows where its `db` or `index` comes from instead of leaving the handle undefined. `@usegraft/sdk-sveltekit` gets the same treatment, and its README ships in this release. It was `private` and so could not be named in a changeset at all. It is public now, at the same version as the rest of the workspace. `cli`, `sdk-astro`, `sdk-core`, `sdk-next` · [36d6045](https://github.com/AndersonDesign1/graft/commit/36d6045) - Publish `@usegraft/sdk-sveltekit`. It sat at `0.1.0` and `private` while every other package in the workspace was at `0.2.0`, and `private` is what kept it there. The release config puts every `@usegraft/*` package in one `fixed` group, and changesets counts a private package as ignored, so naming this one beside any other failed the mixed-changeset check and the release plan refused to build. That left no way to ship a fix for it at all, and it fell one version further behind on each release. It is public at `0.2.0` now and versions with everything else. Nothing in the package itself changed. The README, the adapter and its tests were already written and are untouched. `src/` still tracks `@usegraft/sdk-astro`: the same `createGraft` and `graftRoute`, differing only in doc comments, the `context` to `event` rename that SvelteKit's `RequestEvent` asks for, and the URL one test uses as a fixture. That adapter is exercised by `examples/docs-site`, so this code path has been covered all along and only the packaging held it back. Pin the MinIO images in the self-host Dockerfile to digests. `minio/minio` and `minio/mc` were both on `latest`, which MinIO moves on every release, so the image the README tells people to `docker run` could change from one build to the next while nothing in this repo did, and a bad upstream release would arrive with no way to tell what had shifted. Every GitHub Action here is already SHA-pinned for that reason. `cli`, `sdk-sveltekit` · [3bb7f7b](https://github.com/AndersonDesign1/graft/commit/3bb7f7b) - Add `approvalPolicy: "unattended"`, so a caller with no human behind it can run a destructive function. `"none"` and `"human"` both had no answer for a scheduled job or a CI migration. The destructive arm of the gate had no off switch at all, which is the absence of a policy rather than a policy: those callers could never invoke a destructive function, ever. `"unattended"` turns the gate off entirely. Everything else is unchanged — every invocation still writes its audit row with actor, correlation id and git SHA, and access rules and rate limits still apply. What is given up is the waiting, not the accounting. It is deliberately **not on the MCP surface**. `GraftMcpOptions` accepts only `"none"` and `"human"`, so there is no server setting that makes `run_function` stop asking before a destructive call. The policy exists for a caller with no human behind it; an MCP mount exists because an agent is calling it, and the agent is precisely the party the gate is there to stop. On `graft serve` that split is observable — the env var lifts the gate on `POST /api/fn` and leaves `POST /api/mcp` gated. `graft serve` reads it from `GRAFT_APPROVAL_POLICY=unattended` and warns on every boot while it is on, because an env var is one line in a dashboard and the log is where a mistake gets noticed. Worth being explicit about the trade: git restores authored content, so a deleted document comes back, but it does not restore operational data. `deleteRecord` removes rows outright and the asset store keeps no history. `cli`, `core`, `mcp` · [a442299](https://github.com/AndersonDesign1/graft/commit/a442299) - Refuse elicited approvals over HTTP, and stop a failed prompt breaking the call. Three findings from cubic's review of the pull request, all on `approvalElicitation`. **`createGraftMcpHandler` now throws `CONFIG_INVALID` if given `approvalElicitation`.** It was documented as "a remote or public mount must never set it" and enforced nowhere. A documented caution is the wrong shape for this one, because getting it wrong inverts the gate rather than weakening it: over HTTP the client being asked to approve _is_ the agent that made the call, while `decider` is configured server-side — so an accepted prompt is self-approval recorded under the operator's name. `requested_by_id <> decided_by` lives in the UPDATE's own `WHERE` precisely so that cannot happen, and asking the requester's own client walks around it. `createGraftMcp` (stdio, operator at the machine) still accepts it. **A failed elicitation falls back instead of failing the tool call.** The capability check covered a client that never declared it; a client that declares it and then fails the request — older SDK, a schema it will not render, a transport that times out with the dialog open — let the error escape as a raw `McpError`. It now falls through to the id-and-retry path, the same answer an undeclared capability gets. Nothing is decided, so the row stays pending for a human to find. **The prompt no longer truncates silently.** Inputs were cut at 300 characters with an ellipsis; the limit is now 1000 and the message says it truncated, gives the full length, and points at `graft approvals`. A consent dialog that shows part of what is being consented to without saying so is worse than no preview, because the reader believes they have seen the call. `mcp` · [18ac061](https://github.com/AndersonDesign1/graft/commit/18ac061) - Add `createDocsMcp` / `createDocsMcpHandler`: a public, read-only documentation MCP server, for `/mcp` on a docs domain. It follows what docs platforms already publish. Mintlify generates a docs MCP at `/mcp` for every site it hosts — public, unauthenticated, strictly read-only, offering search plus navigating and reading the docs filesystem. Cloudflare runs a documentation server separately from its authenticated API server. Agents arrive expecting this shape. The surface is `list_collections`, `list_content`, `get_content`, `search_content`, `explain_error`, and the document resources. What is missing is mostly not about writes: `describe_schema` carries the project's functions, `list_registry` its owned primitives, and the branch, compilation and approval listings its operations. Those are all reads, which is why "read-only" is the wrong test for what belongs on a public endpoint. It is a separate factory rather than a flag on `createGraftMcp`, so the authenticated endpoint gains no new way to be opened. `DocsMcpOptions` omits `functions`, `actor`, storage and approval elicitation outright, and `createDocsMcpHandler` has no `allowAnonymous` escape because it has nothing to authenticate. Internally this splits the content tools into read and write registrations, the introspection tools into collection and function halves, and the resources into documents and schema. A mount whose whole purpose is public should not be one `if` away from `write_content`. `mcp` · [ffd5a07](https://github.com/AndersonDesign1/graft/commit/ffd5a07) - Declare `outputSchema` on the 16 tools whose answer has a fixed shape, so `structuredContent` is a validated contract rather than a convenience. The SDK checks every result against the schema before it leaves the server, so a tool that quietly changes shape now fails at its own boundary instead of in whatever the agent tried to do with the answer. Where a contract already existed in `@usegraft/contracts` it is reused rather than restated: `describe_schema`, `describe_function` and `describe_item` were already published shapes with a drift test, and this attaches them to the wire. `run_function` and `delete_content` deliberately keep no schema. Both return whatever the project's own function returns, so a declared shape would promise nothing and cost a validation pass. They are an explicit exemption in the test rather than an omission that looks like an oversight. `mcp` · [e36c09b](https://github.com/AndersonDesign1/graft/commit/e36c09b) - **Security:** `write_content` could write outside `contentDir`. The `slug` argument was `z.string()` with no shape constraint, and the path was built as `join(contentDir, collection, slug + ".mdx")`. `parseDocument`, which runs afterwards, is not a guard against this: it validates `basename(sourcePath)`, so a slug of `"../../escaped"` parses as the entirely legal slug `"escaped"` while the join walks two directories up. Confirmed by running it before the fix — the file landed outside the content tree and the call returned success, not an error. Any caller holding `content:write` could therefore write an arbitrary file anywhere the server process could. That is most of the point of the scope on a local stdio server, and not at all the point on an HTTP mount. Two independent defences, each verified to hold on its own: - `assertSlugShape` rejects a non-kebab-case slug in the handler with `INVALID_SLUG` and a `fix`. Deliberately not a `.regex()` on the input schema — zod rejects before the tool body runs and the SDK surfaces that as a bare protocol error with no `fix`, which is the self-teaching an agent most needs when the correct slug is one edit away. - `resolveContained` decides where bytes actually land, so the guarantee does not rest on the check above still being there. `delete_content` gets the same slug check, ahead of `findDoc` and ahead of the approval, so a malformed slug never reaches a human as a pending decision. The **document resource read** and the **delete** now resolve through `resolveContained` too. Those follow a `sourcePath` produced by a directory scan, which a caller cannot inject — but the scan lists a symlink like any other entry, and following one would serve or unlink a file outside the content tree. That matters most on the read, because the read-only documentation server is an unauthenticated mount. Raised by cubic on the pull request; the write traversal above was found by pulling on it. `mcp` · [04f7576](https://github.com/AndersonDesign1/graft/commit/04f7576) - Add prompts and argument completion. Four prompts, each filled in from the live project: `author-document` and `plan-migration` carry the collection's actual field list, `revise-document` carries the document's resource URI, and `fix-error` resolves the recovery text from this build's own error knowledge. A prompt that only said "author a document nicely" would be a sentence the user could have typed, and would carry no reason to live on the server. They also encode the order of operations an agent has no way to infer: `write_content` validates before it writes, `write_content` replaces rather than patches so untouched frontmatter must come back byte-identical, migrations are reviewable commits, and `graft migrate --apply` is the operator's consent to propose rather than to run. Prompt arguments and resource-template variables autocomplete from what exists: collection names, document slugs narrowed to the collection already chosen, and error codes matched case-insensitively. Picking a collection that is not registered is a mistake the server can prevent rather than diagnose. `mcp` · [23b73fa](https://github.com/AndersonDesign1/graft/commit/23b73fa) - Return `resource_link` blocks from the content tools, so an answer that names documents also says where they are. `list_content`, `get_content`, `search_content` and `write_content` now carry a link per document alongside their text. A tool answering "here are eleven documents" and leaving the client to reconstruct eleven URIs was asking it to know the scheme; a search hit already knows its collection and slug, so the link saves a `get_content` round trip to reach the document itself. The URI scheme lives in one module now, used by both the links and the resource registration. Two spellings of it would be two things to keep in step, and the failure would be silent — a link pointing at a URI nothing serves reads as a broken client rather than a server that disagrees with itself. The test follows a link through `readResource` rather than comparing strings. The text block stays first, so a client that ignores links loses nothing, and a failure emits no links at all. `mcp` · [9859435](https://github.com/AndersonDesign1/graft/commit/9859435) - Serve authored documents and the project schema as MCP resources. A tool call is a request to do something; a resource is a thing that exists, addressed by URI, which a client can list and attach to a conversation without spending a turn deciding to. Graft's documents were reachable only as tool output, so attaching one meant an agent calling `get_content` and pasting the answer — and they are files with stable paths, the most resource-shaped thing in the product. URIs are `graft://<branch>/<collection>/<slug>`, with the branch baked into the template rather than left as a variable a server would only have to refuse. Reads come from the authored files, not the index, so a resource reflects the working tree rather than the last compile, and works on a static project with no database. `graft://<branch>/schema` serves the same payload as `describe_schema`, for attaching once instead of fetching each time. The template's URI variables autocomplete from what exists, and the slug list narrows to the collection already chosen. Also adds `guardedResource`, so a failed resource read carries its `fix`. A tool failure is a value that can hold the code, the fix and the recovery text; a resource read has no such envelope, and a GraftError escaping raw arrived with its fix stripped off. `mcp` · [0db04f7](https://github.com/AndersonDesign1/graft/commit/0db04f7) - Declare what every MCP tool does to the world, and answer with data as well as prose. All 18 tools now carry `ToolAnnotations` — the hints the protocol has had since 2025-03-26 and Graft shipped none of. Every tool looked identical to a client, so `search_content` and `delete_content` were offered on the same terms, and a client that asks a human before a destructive call had nothing to key on. Reads are `readOnlyHint`, `delete_content` / `put_asset` / `decide_approval` / `run_function` are `destructiveHint`, `write_content` is a non-destructive change where `contentDir` is a git work tree, which is the ordinary case and the one the hint is set for — outside one an overwrite has no undo, and the hint is then optimistic. Nothing claims an open world: the domain is the collections a project declares. These are hints and not a boundary, exactly as the spec says. Graft's real gates are unchanged — the scope check, the one-shot input-bound approval, the Postgres role separation. This makes an honest client's UX correct; it does not make a dishonest one safe. Tool results also carry `structuredContent` (MCP 2025-06-18) whenever the payload is an object. Every tool already built a JS object and serialised it, so each caller parsed the prose back into the shape it started as. The text block is byte-identical, so a client that predates the field sees no change. `@modelcontextprotocol/sdk` moves to ^1.30.0. `mcp` · [fe8b02f](https://github.com/AndersonDesign1/graft/commit/fe8b02f) - Add `@usegraft/sdk-tanstack-start`. Same `createGraft` surface as every other adapter, so `getContent("docs", slug)` returns the type your `defineCollection` declared and an unknown collection name is a compile error, plus `graftRoute` for mounting the functions and MCP handlers on a server route. Typed structurally, so the package depends on no TanStack code and there is no peer version to keep in step. The framework-specific warning is about loaders. TanStack Start route loaders are isomorphic — they run on the server for the first paint and in the browser on client-side navigation — so a handle holding a database connection belongs in a server function or a server route, never in a loader. Astro frontmatter is the only adapter here that is server-side unconditionally; SvelteKit has the same split under different names, where a `load` in `+page.server.ts` is server-only and a universal `load` in `+page.ts` also runs in the browser. Add `@usegraft/sdk-react-router` for React Router v7 framework mode, which absorbs the Remix path. Same surface again, with one difference worth knowing before you mount anything: React Router splits a route by method into two exports instead of naming the method, so a functions endpoint exports both `action` (POST) and `loader` (GET). The `loader` line looks redundant and is not — without it a GET gets React Router's own "no loader" error rather than Graft's 405 with an `Allow` header and a fix. Its `graftRoute` test builds a typed `LoaderFunctionArgs`-shaped value rather than passing an object literal inline. TypeScript's excess property check rejects the literal, which would have made the test assert the opposite of what it claims: that the mount accepts only `{ request }`, when the point is that it accepts the larger object React Router actually passes. Add `@usegraft/sdk-react`, the browser client, which only became coherent once `@usegraft/content-api` shipped. The open question was types across the wire, and the answer is that they do not cross it: the app imports its own `collections` from `graft.config.ts` at compile time exactly as a server adapter does, and `createContentApiReader` supplies data over HTTP. No codegen, no generated client, no schema fetched at runtime — the wire carries documents. `createGraft({ endpoint, collections })` returns the same three reads as every other adapter. On top of that sit `createGraftHooks(graft)` and three hooks that run those reads and report `{ data, error, loading, refresh }`. The hooks come out of a factory rather than being importable directly because that is what keeps them typed: a hook reading an untyped context could only promise `Document<AnyCollection>`, which makes `data.title` unknown and a misspelled collection name a runtime surprise. They are a binding, not a cache. No deduplication, no retries, no stale-while-revalidate, and `data` answers the current arguments or is undefined. Apps have TanStack Query or SWR for that and `graft.getContent` is a plain async function that composes with either; a worse copy of a query client inside an SDK is the wrong trade. Two things this turned up. `branch` is silently dropped over the wire — the content API pins its branch server-side and rejects a branch query param, so a client that set one would read main while believing it read a preview. Passing `branch` with `endpoint` is now a `CONFIG_INVALID` at construction that says to point at the preview's own endpoint. And `packages/sdk-react` carries the only jsdom config in `packages/`: `useEffect` never runs without a DOM, so a Node-environment test of these hooks would have asserted the loading state and nothing else — precisely the part that cannot break. `sdk-react`, `sdk-react-router`, `sdk-tanstack-start` · [fcfa66f](https://github.com/AndersonDesign1/graft/commit/fcfa66f) ## 0.2.0 Released August 27, 2026. ### Breaking changes - Approval decisions are attributed to a verified identity, never to caller input. `decideApproval` took a `decidedBy: string` that every surface let the caller supply. That string is the entire separation-of-duties control — the UPDATE's WHERE clause compares it against `requested_by_id` — so anyone who could name it could approve their own destructive operation by naming somebody else. The guard was decorative. **Breaking:** - `decideApproval(db, id, decision, decidedBy)` now takes an `ApprovalDecider` (`{ kind, id }`) instead of a string. - The MCP `decide_approval` tool no longer accepts a `decidedBy` argument. It attributes the decision to the identity the connection authenticated as, via the new `connectionActor` option, and refuses an unauthenticated connection. - `POST /api/studio/v1/approvals/{id}/decide` ignores `decidedBy` in the body; the Studio's `decidedBy` mount option is now `decider: ApprovalDecider`. - An approval whose requester has no stable id is **undecidable** (`APPROVAL_UNATTRIBUTED`). The old `requested_by_id IS NULL` arm made those approvable by anyone, including whoever filed them. - A human-gated function called by an actor with no stable id is refused with `UNAUTHORIZED` instead of filing an approval nobody can be accountable for. Adds a `decided_by_kind` column (migration `0008`) so attribution records what kind of actor decided, matching `requested_by_kind`. `cli`, `contracts`, `core`, `db`, `mcp`, `studio` · [61b9ac4](https://github.com/AndersonDesign1/graft/commit/61b9ac4) - `graft compile` refuses executable MDX, and the project declares its own trust. `MdxBody` refuses `{…}` expressions and `import` at render by default, and the write paths refuse them on the way in. `graft compile` checked nothing, on the reasoning that content already in git arrived through code review. That left the compiler and the renderer disagreeing. A git-authored expression body compiled, indexed, and then failed at render, per request, on the page, in production. Compile now checks every authored body and reports every offending document at once, so the failure lands at build time. `export const mdxTrust = "full"` in `graft.config.ts` is the escape, for the case ADR 0004 names: every author has commit access, so code review really is the control. It defaults to `"restricted"`, and an unrecognised value is refused rather than defaulted. `MdxBody`'s `trust` prop is unchanged. The two settings have to agree, so compile's error names both. **Breaking:** - `graft compile` fails on authored MDX containing `{…}` expressions, `import`, `export` or spread attributes, unless the project sets `mdxTrust = "full"`. Evidence the break is narrow: all 28 authored `.mdx` files across both examples compile unchanged. - MDX the checker cannot parse is refused rather than indexed. **New:** `readDocs` takes a third options argument; `CompileOptions`, `CompileStaticOptions`, `GraftMcpOptions` and `StudioApiOptions` gain an optional `mdxTrust`. All default to `"restricted"`, so a call site that omits it is safe rather than permissive. `MdxTrust` is declared in `@usegraft/mdx-safety` and re-exported from `@usegraft/sdk-next`, which used to declare its own copy of the same union. Same name, same shape, so nothing importing it has to change. See `docs/adr/0006-compile-refuses-executable-mdx.md`. `cli`, `compiler`, `mcp`, `mdx-safety`, `sdk-next`, `studio` · [52d7488](https://github.com/AndersonDesign1/graft/commit/52d7488) - Host validation and CSRF protection for the local Studio. A loopback Studio has no authentication by design, so anything that can reach `127.0.0.1` can act. Browsers let a page do exactly that: every Studio mutation is a plain POST/PUT parsed with `request.json()`, which ignores Content-Type — so a cross-origin "simple request" carrying `text/plain` executed with no CORS preflight. The attacker cannot read the response, but the side effect already happened: an approval decided, a document overwritten, a commit made. **Breaking:** - `createNodeListener(handler, { allowedHosts })` refuses a request whose `Host` is not one it answers to, with 400. `graft serve` and `graft studio` derive the list from their bind address. Without this an attacker-chosen Host flowed into every handler, and a browser resolving any name to `127.0.0.1` is exactly how DNS rebinding reaches a loopback bind. - The Studio API refuses state-changing requests whose `Origin` is cross-origin, and requires `Content-Type: application/json` on them — which forces a preflight for anything that omits Origin. Reads are unaffected. The shell redirect is now `Cache-Control: no-store`: it is built from the request's own Host and fires before any authorization runs, so a 302 cached by path alone would outlive the Host check. The Vite dev proxy rewrites `Origin` to the API's origin, since in development the browser's origin is the Vite server rather than the Studio. `cli`, `studio` · [4bde361](https://github.com/AndersonDesign1/graft/commit/4bde361) - MCP over HTTP fails closed. Authentication is no longer opt-in. `createGraftMcpHandler` had `requireActor?: boolean` defaulting to **off**, on a handler whose own docs advertise embedding it in "a Next.js route, a self-host container, Vercel Fluid, or a Worker". Forgetting it published `write_content`, `put_asset`, `delete_content` and `decide_approval` to anyone who found the URL — and unlike `graft serve`, a library embedding got no warning at all. **Breaking:** - `requireActor` is replaced by `allowAnonymous`, which defaults to `false`. - Constructing a handler with neither an `actor` resolver nor `allowAnonymous: true` now **throws** (`CONFIG_INVALID`). A deployer who forgets gets a startup failure with a fix line instead of an open endpoint. - `graft serve` derives the default from the bind host: anonymous MCP is served on loopback (zero-config local dev) and refused anywhere else. `GRAFT_MCP_REQUIRE_AUTH` is retired — its secure value (`1`) is now the default, so deployments that set it are unaffected. Off loopback, `GRAFT_MCP_ALLOW_ANONYMOUS=1` is a deliberate, warned-about opt-in for operators fronting the server with their own auth proxy. - The insecure-bind warning now tests what is _enforced_. It previously treated "a dev token exists" as sufficient, so setting `GRAFT_DEV_TOKEN` silenced the warning while anonymous callers kept reaching `decide_approval`. `cli`, `mcp` · [2eb24ed](https://github.com/AndersonDesign1/graft/commit/2eb24ed) - One path-containment helper, and it refuses symlinks. `resolveContained(root, path)` (new, in `@usegraft/compiler`) checks the bytes _and_ the filesystem. Lexical containment — `resolve` plus a prefix check — only answers "does this string stay under the root", which a symlink **inside** the root silently defeats: `docs/notes.mdx -> ~/.ssh/id_rsa` passes every string test and `readFileSync` then follows it. Git can commit symlinks, so a cloned template can plant one. **Breaking:** - MCP `put_asset` no longer reads arbitrary server paths. Its `path` argument requires the new `localUploadRoot` option, which only `graft mcp` sets (to the project directory) — every remote mount refuses it. Previously the raw string went to `readFileSync`, the bytes were stored under a caller-chosen key, and the response included a fetchable URL, so one call read `.env` off the server. - Studio `writeDocument` validates the slug against `SLUG_RE` and contains the resulting path. `parseDocument`'s existing check did not help: it validates `basename(sourcePath)`, which strips exactly the `..` segments that make a path dangerous. - `loadItem` validates the item name before joining it onto the registry root. `describe_item` passed a raw MCP argument through, and the three error branches were distinguishable — a filesystem existence oracle. - `safeContentPath` now refuses symlinks, so a hostile repository can no longer leak files through the changes-diff endpoint. Also fixes `looksBinary`, which read an entire file into memory to inspect its first 8KB — one large file made every diff render allocate all of it. `SLUG_RE` is now exported from `@usegraft/compiler`. `cli`, `compiler`, `mcp`, `registry`, `studio` · [92fe85e](https://github.com/AndersonDesign1/graft/commit/92fe85e) - Rate limits key on the real peer address, and concurrency can no longer outrun them. Every rate limit in the product was bypassable with a header. `clientIp` read `x-forwarded-for.split(",")[0]` — the **leftmost** entry, which under XFF's append semantics is whatever the original client wrote. Rotating the header minted a fresh bucket per request, defeating per-function limits, the handler-wide backstop, and the anti-brute-force property they exist for. Separately the limiter counted prior audit rows, ran the handler, and recorded its row afterwards — a window spanning the entire invocation. N concurrent requests all read the same count, all saw room, and all ran. **Breaking:** - `AuditStore.record(entry)` is replaced by `reserve(entry) => id` and `settle(id, outcome)`. The row is inserted before the call is admitted, so the counter and the evidence are the same row. A row left `in_flight` is a crashed or still-running invocation, which is worth being able to see. - `FunctionsHandlerOptions.trustedProxyHops` (default `0`) controls whether `x-forwarded-for` is read at all. At `0` it is ignored entirely. At `n`, the nth entry **from the right** is used — the address your own nearest proxy observed, which a client cannot forge past. Set it to the number of proxies you run. - `runtimeRoleGrantsSql` grants `UPDATE (status, duration_ms) ON audit_log`. Column-scoped deliberately: the runtime may record how a call ended, never rewrite who made it or what it counted against. `PEER_HEADER` (`x-graft-peer`) is exported from `@usegraft/contracts`. Graft's Node adapter strips any inbound copy and sets it from the socket, so unlike `x-forwarded-for` it cannot be written by a client. `cli`, `contracts`, `core`, `db` · [ed103a8](https://github.com/AndersonDesign1/graft/commit/ed103a8) - Authorization is per-route and scope-based. Being authenticated is no longer enough to reach operator-only surfaces. The Studio's `authorize` was `(request) => boolean` — an interface too narrow to express "may this actor do _this particular thing_", so callers invented their own policy. What `graft serve` invented was `actor.kind !== "anonymous"`, which admits every agent: `GRAFT_DEV_TOKEN` mints `{ kind: "agent" }` and OIDC issuers default to the same. Any agent holding a normal runtime token could approve destructive operations, write documents, commit, and revert. MCP had the mirror problem: scopes were consulted only inside `run_function`'s access rules, so `write_content`, `put_asset`, `delete_content` and `decide_approval` were available to any authenticated caller whatever their token permitted. **Breaking:** - `StudioApiOptions.authorize` is replaced by `authenticate`, which returns a `StudioPrincipal` (`{ kind, id, scopes }`) or `null` to refuse. - Studio routes require scopes: `studio:read` for reads, `studio:write` for mutations, `approvals:decide` for the approval decision. The requirement is computed in one place, so a new route cannot be added without one. - MCP `write_content`, `put_asset` and `delete_content` require `content:write`; `decide_approval` requires `approvals:decide`. - Approval decisions made through an authenticated Studio are attributed to the caller, not the mount-time identity. - `graft mcp` grants `content:write` locally (it runs on the operator's own machine) but deliberately not `approvals:decide`, so the CLI requesting and `graft approve` deciding remain different identities. - The landing-page example stops granting every self-registered account `submissions:read commerce:orders:read commerce:orders:write`. Scopes now come from a `GRAFT_OPERATOR_EMAILS` allowlist; one free signup previously dumped every contact submission and could mark arbitrary orders paid. `cli`, `mcp`, `studio` · [6ebfe18](https://github.com/AndersonDesign1/graft/commit/6ebfe18) - Field builders can bound what they accept, and query limits are clamped server-side. `FieldOptions` carried only `optional` and `description`, so every authored string and every public form input compiled to a bare `z.string()` and was written verbatim into an unbounded jsonb column. A single anonymous request could store megabytes; an unbounded quantity multiplied by a price silently exceeded `Number.MAX_SAFE_INTEGER` and stored a wrong total rather than being rejected. **New options:** `maxLength` (string/text), `min` / `max` / `int` (number), `pattern` (string/text), and `maxItems` on `field.array`. **Breaking:** - `listRecords` clamps `limit` to `MAX_RECORD_LIMIT` (500) and coerces nonsense values to the default. It previously passed a caller-supplied number straight to `LIMIT`, so a public query could ask for a billion rows — or a negative one, which made Postgres error. - `listRecords` gains `match`, which filters on `data` fields **in SQL**. Filtering after the row cap is a correctness bug, not just a slow path: non-matching rows still consume the window. `listComments` filtered `approved && pageSlug` in JavaScript afterwards, so posting enough unapproved comments emptied every approved comment on every page, silently. The bundled `comments` and `commerce` primitives now bound every input, `placeOrder` caps `items` at 100, and `loadProducts` batches the catalog lookup into one `inArray` query instead of one round-trip per slug — that loop ran _before_ unknown slugs were rejected, so a request full of bogus slugs held a pooled connection for thousands of serial queries and only then failed validation. `products.currency` is constrained to three letters, so one malformed product can no longer take down the catalog page via `Intl.NumberFormat`. `core`, `registry` · [e0d4eda](https://github.com/AndersonDesign1/graft/commit/e0d4eda) - The hardened runtime role can project content, and the container hardens by default. `graft harden` denied the runtime role `INSERT`/`UPDATE` on `content_index` and `compilations`, so hardening cost a deployment its MCP content writes. That made the second layer of the approval gate something you traded a working feature for, which is why it was opt-in and applied to nothing. The denial was not buying what it looked like. `write_content` writes the MDX file and then compiles, and compile is the step that reaches Postgres, so whoever holds the runtime credential could already project content through the application. Withholding the grant removed a feature, not a capability. The property worth enforcing is narrower and untouched: no `UPDATE` on `approvals`, so `pending → approved` stays unreachable for the runtime even with raw SQL. Removals are a soft delete, so `DELETE` on `content_index` stays ungranted, and `migrations_applied` stays operator-only. **Security fix, found reviewing the above.** The `approvals` INSERT grant was table-level, and `status` is plain text with a `DEFAULT` rather than a `CHECK`. Postgres lets a table-level `INSERT` grantee name every column, so the runtime credential never needed to flip a pending row: it could file one that was already `'approved'` and consume it, and `decideApproval` (with its separation-of-duties predicate) would never run. Withholding `UPDATE` alone was not the control it read as. The grant is now column-scoped to the seven columns an approval request actually writes, so `status`, `decided_by`, `decided_at` and `decided_role` fall back to their defaults. This predates the changes above, but shipped dormant behind an opt-in nobody ran. Turning hardening on by default is what would have made it live. **Breaking:** - `runtimeRoleGrantsSql` emits two more `GRANT` statements. Re-run `graft harden <role>`; existing hardened roles keep working with the old, narrower grants until you do. - The all-in-one container serves under the hardened runtime role by default. Set `GRAFT_HARDEN=0` for the previous behaviour. `GRAFT_MODE=serve` stays opt-in, because there the database is yours rather than the container's. See `docs/adr/0005-hardening-is-the-default-where-the-container-owns-the-database.md`. `db` · [52d7488](https://github.com/AndersonDesign1/graft/commit/52d7488) - Authored MDX no longer executes as JavaScript by default. `MdxBody` compiles with `outputFormat: "function-body"` and evaluates via `run()`, which is `new Function` in the host runtime — so `{expr}` and `import` in a stored body are arbitrary server-side JavaScript with `process`, `fetch` and dynamic `import()`. For content the operator wrote and reviewed in git, that is the feature. It stops being one as soon as an author is not the operator: a hosted Studio, or one a user runs for their own writers, makes "can write content" mean "can execute code on the render host" — and on shared infrastructure, on other tenants' hosts too. New package **`@usegraft/mdx-safety`**. It removes the executable surface rather than trying to contain it: `node:vm` is explicitly not a security boundary, and a worker thread cannot return React elements without breaking component identity under RSC. Prose, GFM and components with literal attributes all survive; `{…}` expressions, `import`, `export`, expression-valued attributes and `{...spread}` attributes are refused. **Breaking:** - `MdxBody` gains `trust?: "restricted" | "full"`, defaulting to `"restricted"`. Pass `"full"` only for bodies you know came from your own repository. - MCP `write_content` and Studio document saves refuse executable MDX. Checked at render as well as at write, because content can also arrive through a direct database write with the runtime credential — a path no write-side guard sees. All 28 authored files across both examples pass unchanged. `mcp`, `mdx-safety`, `sdk-next`, `studio` · [1aea0da](https://github.com/AndersonDesign1/graft/commit/1aea0da) ### Features - Every package ships a README, a description, keywords and a LICENSE. `0.1.1` published sixteen packages with no README and, for fourteen of them, no `description` either. On npm that renders as a blank page and an unsearchable listing: `description` is the line npm search shows, and without keywords the packages are findable only by exact name. Each README says what the package is, how to install it, and shows one real example using its actual exports. The security-relevant ones state their defaults plainly, because "MdxBody refuses executable MDX by default" is something a reader should not have to find in an ADR. `LICENSE` is now copied into each package. `files: ["dist"]` does not exclude `README.md` or `LICENSE` (npm always packs those), but a licence file only ships if it exists in the package directory, and the root one does not count. `assets`, `auth`, `cli`, `compiler`, `content-migrations`, `contracts`, `core`, `db`, `mcp`, `mdx-safety`, `registry`, `sdk-astro`, `sdk-core`, `sdk-next`, `studio`, `tokens` · [f423a6e](https://github.com/AndersonDesign1/graft/commit/f423a6e) - Thirteen self-contained defects, led by a data-loss bug in the editor. - **The editor wrote one document's content into another document's file.** `persist` took `collection`/`slug` from the current route closure while taking the bytes from a ref. On an A → B navigation React re-renders with route=B before the pending flush runs, so A's edits were written to B's path, destroying it — and the toast said "Saved B/B". Identity now comes from the same snapshot as the content, via a new `buildSavePayload` that takes it as an argument, which makes the mismatch unrepresentable. Document loads are sequenced so out-of-order responses cannot show one document under another's route. - `updateRecord` read, merged and wrote with no lock, so two concurrent callers both merged over the same baseline and the second silently erased the first. Now one transaction with `SELECT … FOR UPDATE`, and the "unreachable" `throw new Error` is a real `DOCUMENT_NOT_FOUND`. - A malformed URL escape white-screened the Studio (`parseHash` runs in a `useState` initialiser, so the `URIError` threw during first render) and turned Studio asset and route-id requests into misleading 500s. - The compiled static index briefly did not exist: `rmSync` then `renameSync` left a window where readers got `STATIC_INDEX_NOT_FOUND`, and a crash between them destroyed the artifact. Now an atomic rename, falling back only on Windows. - Data migrations persisted the raw transform output instead of the validated result, so Zod defaults and coercions never reached the stored rows, and compared with key-order-sensitive `JSON.stringify` against jsonb that normalises key order — rewriting identical rows. - The generated `graft/index.ts` barrel emitted duplicate imports when two filenames collapsed onto one identifier (`my-mod.ts` and `myMod.ts`), in a file marked "do not edit". It now refuses and names both offenders. - `graft dev` kept watching the original content directory after a config reload moved it, so every later save was invisible. - OIDC tokens without a `sub` claim authenticated as an actor with no id. `requiredClaims: ["sub"]` now rejects them. - `applyPlan` trusted a conflict snapshot taken when the plan was built; it now re-checks disk immediately before each write. - A `??` chain in the pages-description migration short-circuited on an empty string, so the documented title fallback was never reached. - All four GitHub Actions are pinned to commit SHAs, and the release workflow's `id-token: write` is scoped to the publish job rather than the whole file. `auth`, `core`, `db`, `registry`, `studio` · [d6cbc3d](https://github.com/AndersonDesign1/graft/commit/d6cbc3d) - Fixes found by independent review of the hardening work itself. - **The rate-limit peer is no longer a header.** `x-graft-peer` was stripped and re-set by Graft's Node adapter, which is sound for `graft serve` and worthless in a Next.js or Astro route that passes the browser's Request through untouched — a client could send the header and choose its own bucket. That is the `x-forwarded-for` bug, relocated to a header nobody knew they had to strip. The peer is now registered against the Request object in-process (`setRequestPeer` / `getRequestPeer`), which nothing over the wire can forge. `PEER_HEADER` is removed. Deployments with no adapter share one `unknown` bucket unless they declare `trustedProxyHops`; both examples now do. - **`@usegraft/mdx-safety` parses what the renderer parses, and fails closed.** The checker used `remark-parse` + `remark-mdx` while `MdxBody` compiles with `remark-gfm` — so source that failed to parse here but compiled there was waved through by the old "unparseable means nothing to execute" shortcut. GFM is now enabled on both sides, and unparseable source throws `UncheckableMdxError` instead of returning `[]`. - **Scripting elements and inline event handlers are refused.** `<script>alert(1)</script>` and `<img onerror="…">` contain no `{}` expression, so the expression checks never saw them. The module now documents that it is not a general HTML sanitiser. - **`createGraftMcp` fails closed when `actor` is set without `connectionActor`.** That combination silently disabled every MCP write-tool scope check, and it shipped in one of our own example scripts. `cli`, `contracts`, `core`, `mcp`, `mdx-safety` · [301c817](https://github.com/AndersonDesign1/graft/commit/301c817) - An approval is filed pending by construction. Migration `0009_pending_by_construction` adds a `CHECK` on `approvals.status` and a `BEFORE INSERT` trigger that refuses any insert naming a status other than `'pending'`, or carrying a decision in `decided_by`, `decided_at`, `decided_role` or `decided_by_kind`. The column-scoped INSERT grant already stopped a hardened runtime role from filing an approved approval. That is a grant list, and a grant list is a thing someone edits. This puts the same rule in the table, so it holds for every role including the owner, and no future change to `runtimeRoleGrantsSql` can quietly reopen it. The trigger raises rather than coercing the row to `'pending'`. A caller trying to file a decision has a bug or is an attacker, and silently rewriting the row would hide both. Deciding is an `UPDATE` and is untouched. **Requires a migration.** Run `graft db migrate` (or `node packages/db/scripts/migrate.mjs`) before serving on an existing database. `db` · [02690dd](https://github.com/AndersonDesign1/graft/commit/02690dd) ## 0.1.1 Released August 25, 2026. ### Fixes - `graft --version` reported `0.0.0` instead of the released version. The version was a hardcoded constant that changesets never touched, and the test asserted `toContain("0.0.0")` — so it passed _because_ of the bug. The version is now read from the manifest at runtime, and the test asserts against that value rather than a literal. `cli` · [6737b5b](https://github.com/AndersonDesign1/graft/commit/6737b5b) ## 0.1.0 Released August 25, 2026. ### Features - Initial public release of the Graft packages: contracts, core, db, assets, compiler, content-migrations, auth, sdk-core, sdk-next, sdk-astro, sdk-sveltekit, cli, mcp, registry, studio, tokens. All packages version together (fixed group) while pre-1.0. `core` · [8d8eda0](https://github.com/AndersonDesign1/graft/commit/8d8eda0)