Publish a folder of Markdown from git
forumulate publish <folder> makes a folder of Markdown into live pages. When you run it again, it changes only what changed, then reads the space back and checks that the change landed. It is part of @forumulate/cli from version 0.4.0.
Without it, every team that keeps its docs in a repository ends up writing the same publisher. That script maps front matter to pages, keeps re-runs idempotent, prunes removed pages, keeps the order, reads the result back, supports a dry run, and imports an OpenAPI reference. This command does all of those things.
The short version
npm install -g @forumulate/cli
forumulate login # or FORUMULATE_TOKEN in CI
forumulate publish ./docs --org acme --space docs --dry-run # the plan
forumulate publish ./docs --org acme --space docs # do itA dry run writes nothing. Against a public space it needs no token: it reads the published space anonymously and compares your folder against that.
forumulate sync <folder> is the same command the other way round: it prints the plan unless you pass --apply. It also covers FAQ questions and blog categories, and --diff shows what changes inside each piece. See Content as code.
How a folder becomes a space
For a docs space (and the other page-based types: legal, FAQ, careers, contact, courses, custom):
On disk | In the space |
|---|---|
| pages in the space's default collection (or |
a folder, | a collection called "Guides", second in the sidebar |
a folder inside a collection, | a parent page, and the folder's files nest under it |
that folder's | the parent page itself (without one, a title-only page stands in) |
| that collection's |
an image or PDF a page points at | uploaded once, and the reference is rewritten to the uploaded URL |
a link to another | rewritten to that page's address |
For a blog, a changelog, a forum or a roadmap, every Markdown file in the folder is one post. A 2026-10-01-launch.md file name gives the post its date.
Some names are never content: README, LICENSE and the other repository files, anything starting with . or _ (such as _drafts/), and the manifest. In a posts folder a README is skipped wherever it is. In a docs folder it stays the folder's own page unless the space says readme: skip. The plan lists every file it skipped, and ignore: on a space adds globs of your own. The full list is in Content as code.
A number at the start of a name (01-, 02_) sets the order and is left out of the address. 01-install.md is published at install.
Front matter
---
title: Install the agent # else the first "# heading"
slug: install # else the file name, without its number
description: Two minutes, one command.
icon: download
tags: [setup, cli]
order: 2 # also: sidebar_position, nav_order, weight
sidebar_label: Install # the shorter name in the sidebar (navTitle)
hidden: true # out of the sidebar, still published
redirect_from: [/old-install] # addresses that now redirect here
draft: true # left out, and the plan says so (never pruned either)
---Any field the page model takes can go in the front matter under its own name, for example seoTitle, noindex, layout, hero, badge, canonicalUrl, pageChrome or themePatch. snake_case works as well. The parameters a space type declares, such as audience and deprecated on docs, go in the same way. The common names other tools use are mapped: Docusaurus sidebar_label and sidebar_position, Jekyll nav_order and nav_exclude, Hugo weight and linkTitle. The plan lists any key it does not understand and leaves it unsent, so a misspelt key does not get dropped without a trace.
The body goes through unchanged, including our Markdown dialect (:::note, tabs, admonitions, MDX components). The same converter that powers the importer reads it.
To clear a field the command set earlier, set it to null. The command never removes a field just because a key is no longer in the file, because it cannot tell whether it set that value or someone set it in the studio.
Re-runs, prune and order
Pages are matched by slug. A changed page is updated in place, at the same address. A file whose words have not changed is not sent at all.
The comparison looks at the words. Wrapping, the title heading and link targets are ignored, so a change to formatting alone (for example, making a word bold) is not detected. Pass --force to re-send everything.
--prune removes what the folder no longer has: pages and posts with no file, and collections with no folder. Pages go to the trash, where you can restore them. Prune never deletes:
anything in a collection an
openapi:import owns,anything listed in the manifest's
keep:,a page that still has kept pages under it,
anything at all on a run narrowed with
--only.
Order comes from order, then the number prefix, then the name. Only the pages that are out of place are moved. Where the API has a single call to order a whole collection, the command uses it. Otherwise it moves one page per request, with a gap between requests (--pace <ms>, 250 by default).
The manifest
forumulate.yaml (or .yml, or forumulate.json) at the top of the folder describes everything about the space that is not a page:
org: acme
site:
slug: main
config: { tagline: 'Docs, forum and blog in one place' }
spaces:
- path: docs
space: docs
create: { type: DOCS, name: Docs } # make the space if it is missing
theme: { primaryColor: '#0a7f5a' } # PATCH …/theme (deep merge)
settings: { defaultVersionLabel: Latest }
collections:
02-guides: { name: 'Guides & recipes' }
openapi:
- spec: openapi.yaml # or url: https://…
collection: API reference
mode: per-tag
keep: [changelog-archive]
- path: blog
space: blog
- path: status
space: status
monitors:
- { name: API, url: 'https://api.acme.com/health', intervalSeconds: 60 }Each part is compared before it is written. A theme or site config that already holds every key you name is left alone. Monitors are matched by name. The OpenAPI reference is imported every run through POST …/docs/openapi/import, which converges on the spec. An unknown key in the manifest is an error, not something the command silently skips.
Narrowing a run
--only takes slugs, files, folders, a space's folder, or the name of a manifest part (site, theme, settings, openapi, monitors). You can combine them with commas:
forumulate publish . --only docs/01-start,themeRead-back
After a real run, the command plans again against what is now live. If that plan still has anything to do, the change did not land. The command lists each such change and exits 1. Use --no-verify to skip this step, for example on a very large space.
--json prints the plan, the errors and the read-back result in a form a pipeline can read.
Or let pushes sync the space
The CLI publishes when your pipeline runs it. Git sync is the other way round: you connect the space to a repository once, and the Git host tells Forumulate about every push.
PUT /orgs/{orgId}/spaces/{spaceId}/github (space.update) connects a repository on GitHub, GitLab or Bitbucket: provider (github by default), repo, branch (main by default), path and an access token. Its answer, like GET /orgs/{orgId}/spaces/{spaceId}/github, carries webhookUrl. Add that URL as a webhook on the repository. It ends in a secret issued for this connection, and connecting again keeps the same secret, so a webhook you already added keeps working.
The host then calls POST /git/webhook/{secret}. POST /github/webhook/{secret} is the same receiver at its older address, kept for webhooks added before the rename. Once the delivery is verified (see below) it answers 202 straight away and does the work afterwards, because hosts expect a fast answer:
A push to the connected branch imports the repository into the space again, as
POST /orgs/{orgId}/spaces/{spaceId}/github/importdoes. A push to any other branch is ignored, and so is every push to a changelog space.A merged pull request from a branch Forumulate opened for a change request (
POST /orgs/{orgId}/spaces/{spaceId}/github/change-requests/{crId}/pull-request) merges that change request, which is what publishes it.A GitHub
releaseevent on a changelog's connection becomes that release in the changelog. On any other space it is dropped.
With contentSync: true on the connection (and contentSyncPrune for prune), a push runs the content-as-code sync instead of the import: the connected path and its forumulate.yml become posts, pages, FAQ questions, releases and blog categories, exactly as forumulate sync --apply would make them. POST /orgs/{orgId}/spaces/{spaceId}/github/sync runs it by hand (a plan unless {"dryRun": false}), and GET /orgs/{orgId}/spaces/{spaceId}/github/sync-runs lists what each run did. See Content as code.
The secret in the URL says which connection a delivery is for; it does not prove who sent it. That is the signing secret's job. Connecting a space for the first time answers it once, as signingSecret in the connection, and the console's Git sync panel shows it under Webhook secret. It is never in the URL, and GET never returns it. Enter it as the webhook's secret on GitHub or Bitbucket, which then sign each delivery with X-Hub-Signature-256 or X-Hub-Signature (an HMAC-SHA256 of the body), or as the secret token on GitLab, which sends it as X-Gitlab-Token. Every delivery, whatever the event, needs that proof: one without it, or with one that does not match, is answered 401 and nothing happens. An unknown secret is answered 401 the same way.
POST /orgs/{orgId}/spaces/{spaceId}/github/signing-secret (space.update) issues a new signing secret and answers it once; deliveries signed with the old one are refused from then on. Connections made before October 2026 have no signing secret yet. The connection's signing field says legacy, and their deliveries are still verified with the URL secret, which is how those hosts were set up. Anyone holding the URL can sign with that, so generate a signing secret and enter it on the host. Connecting again keeps whatever signing secret the connection has, so it never breaks a host that works.
The work runs as the member who connected the space. The receiver takes 60 requests a minute.
Answer questions from GitHub issues
Separately from publishing, you can connect a repository so people can ask your documentation questions in its issue and pull-request comments. A new comment containing /forumulate ask <question> or /forumulate search <query> is answered with a grounded reply over your public docs spaces; any other text after /forumulate is searched. ask needs an AI provider connected to the organization, and without one the reply says to use search. Comments by bots are ignored. Only published, publicly visible content is ever used. This needs org.manage_integrations.
POST /orgs/{orgId}/github-agent
{
"repoFullName": "acme/product",
"webhookSecret": "a-long-random-string",
"accessToken": "<token that may comment on that repo>"
}repoFullName must be owner/repo. webhookSecret (8 to 200 characters) is the secret you also enter on the GitHub webhook, which should point at /integrations/github-agent/webhook on the API and send issue-comment events; every delivery is verified against it. accessToken is optional: without it the agent can read the webhook but cannot post the reply, and the status says so. Connecting again replaces the previous settings, since an organization has one.
Call | What it does |
|---|---|
| Returns |
| Disconnect. |
In GitHub Actions
# .github/workflows/docs.yml
name: Publish docs
on:
push:
branches: [main]
paths: ['docs/**', 'forumulate.yaml']
pull_request:
paths: ['docs/**', 'forumulate.yaml']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
# On a pull request, show the plan and write nothing.
- if: github.event_name == 'pull_request'
run: npx -y @forumulate/cli@0.4 publish . --dry-run
env:
FORUMULATE_TOKEN: ${{ secrets.FORUMULATE_TOKEN }}
# On main, publish and remove what was deleted.
- if: github.event_name == 'push'
run: npx -y @forumulate/cli@0.4 publish . --prune
env:
FORUMULATE_TOKEN: ${{ secrets.FORUMULATE_TOKEN }}Create the token with read and write, limited to the one organization (see API tokens and scopes). Store it as the repository secret FORUMULATE_TOKEN.
In the API
Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.
Github
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the GitHub in a space |
| |
| Replace the GitHub in a space |
| |
| List egress IPs in a space |
| |
| Create the signing secret in a space |
| |
| Import the GitHub |
| |
| Run the content sync of a connected repository |
| |
| List the content-sync runs in a space |
| |
| Get a content-sync run, with its plan |
| |
| Export the GitHub |
| |
| Create the pull request in a space |
|
Github Agent Admin
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the GitHub agent in an organization |
| |
| Create the GitHub agent in an organization |
| |
| Delete the GitHub agent in an organization |
|