- name
- cr-create
- metadata
- {"version":"1.0"}
- description
- Drive an end-to-end GitBook docs review flow from Claude Code by calling the GitBook REST API directly with curl (no CLI) — create a change request, push content (update an existing page AND create a new page), request reviewers, notify Slack, then pull review comments back in, fix them, re-push, and resolve. This is the authoring-side companion to cr-review (the reviewer side over the same API). Use this whenever someone wants to run a "docs review in GitBook" loop from the terminal/agent against the raw API (curl/HTTP), mentions creating a change request via the API, pushing content into a CR, "pull in the latest comments and fix them," requesting review on docs, or showing engineers how to collaborate on GitBook docs from Claude + Slack without a CLI.
# GitBook Review Flow (direct API)
Run a documentation review loop against a GitBook space entirely through the
**GitBook REST API** (`https://api.gitbook.com/v1`, hit with `curl`), so an engineer
never has to leave Claude Code (plus Slack) to propose docs changes and get them
reviewed. This is the authoring-side companion to `cr-review` (the reviewer side over the
same API). Every action here is a plain HTTP call — there is no CLI and no helper script.
The same actions serve three purposes with no separate code paths:
- **CR-creation demo** — create a change request and push content (one existing page updated, one new page created).
- **Notify/review demo** — request reviewers, drop a Slack link, pull comments, fix, re-push, resolve.
- **Real use** — the identical actions against the user's own content.
Because the demo is just a scripted sequence of the real actions, it cannot show
something that doesn't actually work. Keep it that way: never fake an output.
## Auth and the `gbapi` helper
Every call is a Bearer-authenticated request to `https://api.gitbook.com/v1`. The token
lives in **`GITBOOK_TOKEN`** in the repo-root `.env` (create one at
https://app.gitbook.com/account/developer).
**Never print the token; never write it to a tracked file.** If it's missing, prompt the
user for it and write it to `.env`; don't invent one.
Define this shell helper once per session and use it for every call below. It loads the
token from `.env`, sets the base URL and headers, and — critically for the "never fake
output" rule — **fails loudly on any non-2xx, printing the API's error body** (`curl
--fail-with-body`, curl ≥ 7.76 / stock on current macOS):
```bash
set -a; [ -f .env ] && . ./.env; set +a # load GITBOOK_TOKEN (and SLACK_WEBHOOK_URL)
gbapi() { # gbapi METHOD /path [extra curl args…]
local method="$1" apipath="$2"; shift 2 # NB: not `path` — in zsh that is tied to $PATH
curl -sS --fail-with-body -X "$method" \
"https://api.gitbook.com/v1${apipath}" \
-H "Authorization: Bearer ${GITBOOK_TOKEN}" \
-H "Content-Type: application/json" "$@"
}
```
Every response is **JSON** — pipe it through `jq` and read whole objects. **Never hand-parse
by grepping/line-pairing fields** (bind the wrong title↔id and you act on the wrong
space/CR). If `gbapi` exits non-zero, surface the printed error — do not report success.
## Endpoint map (verified against api.gitbook.com/openapi.json)
`<space>`, `<cr>`, `<pageId>`, `<commentId>` are the relevant IDs. Base URL is
`https://api.gitbook.com/v1`; all paths below are relative to it.
| Step | Method + path | Notes |
|------|---------------|-------|
| Who am I | `GET /user` | returns `{id, displayName, email}` — your own user ID is `.id` |
| List pages | `GET /spaces/<space>/content/pages` | flat-ish tree with `id`, `title`, `type` |
| Get a page (base) | `GET /spaces/<space>/content/page/<pageId>?format=markdown` | current markdown of a page on the live space |
| Create CR *(GATE)* | `POST /spaces/<space>/change-requests` body `{"subject":"…"}` | returns the CR object with `id` and `urls.app` (also a `Location` header) — **`urls.app` is only the editor/diff link, not a rendered preview**; see "Surfacing the preview link" |
| Get CR | `GET /spaces/<space>/change-requests/<cr>` | `subject`, `status`, `createdBy`, `comments`, `urls.app` |
| Push content | `POST /spaces/<space>/change-requests/<cr>/content` body `{"changes":[…]}` | 1–50 ops, applied sequentially in one new revision; all-or-nothing |
| Find the site behind a space | `GET /spaces/<space>` → `.organization`; `GET /orgs/<org>/sites`; `GET /orgs/<org>/sites/<site>/site-spaces` → match `.items[].space.id` | needed only to resolve the site preview link (see below); a space isn't required to belong to a site |
| Get a site (for its preview link) | `GET /orgs/<org>/sites/<site>` | `urls.preview` (draft/CR content), `urls.published` (only once live) — **not part of the change-request response at all** |
| Get a page (CR side) | `GET /spaces/<space>/change-requests/<cr>/content/page/<pageId>?format=markdown` | verify what actually landed in the CR |
| Request reviewers *(GATE)* | `POST /spaces/<space>/change-requests/<cr>/requested-reviewers` body `{"users":["…"]}` | array of user IDs; optional `subject`/`description` |
| List comments | `GET /spaces/<space>/change-requests/<cr>/comments?format=markdown&status=all` | bodies at `body.markdown`; location under `target.page`/`target.node`; poster at `postedBy.id` |
| Reply to a comment | `POST /spaces/<space>/change-requests/<cr>/comments/<commentId>/replies` body `{"body":{"markdown":"…"}}` | |
| Resolve a comment *(GATE)* | `PUT /spaces/<space>/change-requests/<cr>/comments/<commentId>` body `{"resolved":true}` | resolves unconditionally — no reply-first guard (enforce it yourself) |
| Reply list (verify) | `GET /spaces/<space>/change-requests/<cr>/comments/<commentId>/replies` | confirm a reply exists before resolving |
Not a GitBook API operation: any Slack/Channels action. Slack is sent **separately** (see
"Slack is a stopgap").
### Content-change ops (the `changes` array)
Each item in `changes` is discriminated by `operation` (OpenAPI
`ChangeRequestContentChange`):
- **`update_page`** — update document and/or metadata on an existing page.
- Body document: `{"operation":"update_page","page":"<pageId>","document":{"markdown":"…"}}`.
`document` accepts **only** `{"markdown":"…"}` — **not** the node tree that
`GET …/page` returns with `format=document` (pushing that 422s). When `document` is
provided it replaces the whole page body — fetch, edit, push, or you drop blocks.
- **Rename / metadata** (same op, no document required): set `title` and/or `slug`, plus
optional `emoji`, `icon`, `description`, `hidden`, `noIndex`, `noRobotsIndex`, `condition`
(`RevisionPageUpdate`). Title-only rename: set `title`, omit `slug`. URL-only: set `slug`,
omit `title`. Example:
`{"operation":"update_page","page":"<pageId>","title":"New title","slug":"new-slug"}`.
- **Move**: `into` (parent page id, or `null` for root) and/or `at` (zero-based sibling index).
- For Git-synced spaces, also update `SUMMARY.md` when the nav title/path should change.
- **`insert_page`** — `{"operation":"insert_page","title":"…","document":{"markdown":"…"}}`.
`into` (parent page ID) is **optional** — omit it to insert at the space root; `at` (index)
is also optional. Title can come from `title`, markdown frontmatter, or the leading heading.
- **`delete_page`** — `{"operation":"delete_page","page":"<pageId>"}`. This flow never deletes;
documented for completeness.
- **`insert_files`** — upload images/attachments into the CR:
`{"operation":"insert_files","files":[{"name":"diagram.png","url":"https://…","ref":"diagram"}]}`
(or `base64` + `contentType`, max 1MB). Prefer `url`. Reference as ``
or via batch `ref`. Do **not** rely on inline `data:` URIs in markdown.
- **`revert_page`** — `{"operation":"revert_page","page":"<pageId>"}` (optional `revision`,
optional `block` array). Omitting `revision` reverts to the CR base revision.
**`compat` query param** on `POST …/content`: pass `?compat=false` so the response includes
the affected-page list (`ChangeRequestContentUpdate`). Default is still `true` today but will
flip; prefer `compat=false` now.
The markdown round-trip is **LOSSY** — see "Editing an existing page safely" before you
re-push an edited page.
### API behaviors to watch
- **Every endpoint returns JSON.** `GET /user` is just JSON with an `.id` — pipe every
response through `jq`.
- **The `authors` comment filter works server-side.** `GET …/comments?authors=<id>` is a
real array query param (repeat `authors=` for several). You still pull **all** comments and
split human vs agent on `postedBy.id` (see "Two operations") — a filter narrows, it doesn't
classify — but the server-side filter is available if you want it.
- **Nothing normalizes content for you.** The API does not strip the duplicated leading H1 or
collapse multi-line `{% … %}` blocks before sending. **You must do those transforms
yourself** before every push (see "Editing an existing page safely"). This is the easiest
thing to get wrong — don't skip it.
## Surfacing the preview link (do this every time)
**This rule is transport-agnostic — it applies whether the change request was pushed via this
skill's `curl` calls, or via `configure-site`/`write-docs` over the GitBook MCP server.** The
underlying gap is the same in both cases: nothing in the change-request response points at the
rendered preview, so it's easy to file this under "REST-only demo detail" and skip it when the
push actually happened over MCP. It isn't optional in either case. If you reach this skill's docs
while working from `configure-site` or `write-docs`, translate the REST calls below to their MCP
equivalents (`getSpaceById`, `list_sites`/`get_site_structure`, `getSiteById` via
`invoke_operation`) rather than skipping the step because the transport doesn't match.
A change request's own response only ever gives you `urls.app` — the link to the **editor /
diff view** in the GitBook app. It is easy to stop there and assume that's "the link" for the
CR. It isn't the link most people actually want: someone who isn't going to comment or edit
just wants to **see the docs rendered with this change applied**, and that's a different URL
that GitBook calls the **site preview**.
The site preview link is **not exposed anywhere on the change-request object** — verified
against the `ChangeRequest` schema, whose `urls` only has `app` and `location`. It lives on the
**`Site`** object instead, nested under `urls.preview`, which you only ever see if you
separately resolve the site behind the space. Nothing in the CR-creation or content-push flow
points you at it, so it's easy to never discover it exists at all.
Resolve it once per space (cache the result for the session) and mention it **alongside**
`urls.app` every time you create a CR or push content to one:
```bash
ORG=$(gbapi GET "/spaces/<space>" | jq -r .organization)
SITE=$(gbapi GET "/orgs/$ORG/sites" | jq -r '.items[].id' | while read -r s; do
gbapi GET "/orgs/$ORG/sites/$s/site-spaces" \
| jq -e --arg space "<space>" '.items[] | select(.space.id == $space)' >/dev/null \
&& echo "$s" && break
done)
if [ -n "$SITE" ]; then
# A public site's published URL needs no sign-in and never expires; anything else
# (unlisted, visitor-auth, not yet published) has to go through the preview host.
BASE=$(gbapi GET "/orgs/$ORG/sites/$SITE" \
| jq -r 'if .visibility == "public" and .urls.published then .urls.published else .urls.preview end')
NUM=$(gbapi GET "/spaces/<space>/change-requests/<cr>" | jq -r .number)
echo "${BASE%/}/~/changes/${NUM}/" # ← the preview link for THIS change request
fi
```
- **The `~/changes/<number>/` segment is what scopes the link to your change request.** A
bare site URL — `urls.published` or `urls.preview` — renders whatever the site currently
holds, so it loads fine and shows the wrong thing. Both come back from the API with a
trailing slash, so strip it before appending or you emit a double slash.
- **`urls.published`** — the live site URL; only present once the site has been published.
Prefer it when the site is public: no sign-in, no expiry, safe to paste anywhere.
- **`urls.preview`** — the site preview host. Viewers still need access to the site and are
asked to sign in, so it's a worse link to hand to someone — use it only when there's no
public published URL.
- **Preview only exists when the space is attached to a published docs site** — not for a bare
space with no site, and GitBook itself disables the preview UI for share-link / visitor-auth
sites. If the site-spaces search above finds nothing, say so plainly (*"this space isn't on a
published site, so there's no rendered preview link — here's the editor link"*) rather than
silently only giving `urls.app`.
- If a space is unexpectedly attached to more than one site, resolve and mention all of them
rather than picking one.
Use the CR's `number`; its `id` works too but is longer. A **draft** change request previews
fine — you don't have to open it first.
**Check the link before you send it.** `curl -sL -o /dev/null -w '%{http_code}\n' "<url>"`. A
404 means the wrong number or the wrong site. A 200 is necessary but *not* sufficient — an
archived CR returns 200 as well — so when it matters, fetch a page the CR touched and confirm
it differs from the same path on the live site.
Report both links together, e.g.: *"Change request #42 created — [review the
diff](…urls.app) · [preview the rendered docs](https://docs.example.com/~/changes/42/)."*
Note the preview link carries the `~/changes/42/` segment; a bare site URL is not a preview
of this change request.
## Prerequisites
- **`curl` and `jq`** on your `PATH`, and network access to `api.gitbook.com`.
- **`GITBOOK_TOKEN`** in the repo-root `.env` (see "Auth"). Confirm with `gbapi GET /user`
before running actions.
- The **space ID** of the target space (and, for the demo, the page ID to update and a
GitHubで見る