cr-create
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, an
By gitbookio · 373 installs
npx skills add gitbookio/gitbook-skills --skill cr-create
Source repository · Upstream listing
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):
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 :
update page — {"operation":"update page","page":"<pageId ","document":{"markdown":"…"}} .
REPLACES the whole page document. document accepts only {"markdown":"…"} — not
the node tree that GET …/page returns with format=document (pushing that 422s). It
cannot rename a page (there is no title / slug field). Fetch the current markdown,
edit it, push it back — or you drop existing blocks.
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 is required (only insert page sets a title, at creation).
delete page — {"operation":"delete page","page":"<pageId "} . This flow never deletes;
documented for completeness.
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:
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
parent page ID for the new page). references/gitbook review.config.json records these as reference
values for the operator; nothing reads it automatically — pass IDs into the calls.
A GitBook space with Git Sync wired to the docs repo, if you intend to merge
(this flow does not merge).
For Slack: a SLACK WEBHOOK URL in .env (Slack incoming webhook) — the only
supported Slack path, used solely by the separate Slack step. If it isn't set, prompt
the user for it and write it to .env before sending; never invent one or skip silently.
Hard rules
Never invent IDs, URLs, comment text, or "success." Run the call and report exactly
what the API returns. If gbapi errors, surface the error body — don't paper over it.
Confirmation gates — pause and get an explicit yes before any of these state changing /
public actions:
1. POST …/change requests (creates a change request)
2. POST …/requested reviewers (assigns reviewers — notifies a real person). Never
auto pick a reviewer: confirm who with the user. Don't guess from the member list.
3. the Slack notification (posts publicly)
4. PUT …/comments/<id with {"resolved":true} and any merge (closes the loop / changes
shared state)
Content pushes and pulling comments do not need a gate.
Reply before you resolve (enforce it yourself). The resolve call sets resolved:true
unconditionally — the API has no reply first guard. So the skill must confirm the comment
carries a reply before resolving (see "Clos