End-to-end walkthrough
This is the whole journey from an empty directory to a live page, following the
bundled acme-integrations worked example (a fictional SaaS that connects
to third-party tools, scaffolded with gtmesh init --template acme) and a Slack
keyword pull.
The one idea: the committed graph is desired state
Everything orbits the committed graph (graph/nodes.jsonl + graph/edges.tsv) —
the source of truth for every page that could exist and what state each is in. You
never hand-edit it. Instead you change an input (a keyword pull, a config rule, a
page’s status) and then run apply, which makes reality match. apply is
idempotent and Terraform-style: it previews what it will do and prompts before
writing, so it is always safe to re-run. If a term below is unfamiliar
(catalogue, seal, manifest), see the glossary.
plan is optional. apply computes the same diff itself. Run plan
whenever you want a read-only preview; you never need it between a change and
an apply.
Prerequisites
pnpm dlx @benfoster/gtmesh init … # one-shot from the registry; the mesh then pins its own — see [Installation](/cli/installation/)
export AHREFS_API_TOKEN=… # required for `pull demand`; never commit itThe steps
Initialize + run the operator
gtmesh init scaffolds a clean mesh repo immediately — no pre-init step. This
walkthrough follows the --template acme overlay, which fills the repo with a
worked example (the acme-integrations SaaS mesh) so every step below has real
content to act on:
mkdir acme-integrations && cd acme-integrations
gtmesh init --template acme # overlays the acme-integrations worked example on the generic base
# …a plain `gtmesh init` scaffolds a clean, demo-free repo instead:
gtmesh init # placeholder config, empty reference CSVs, generic foundations/schemasgtmesh init writes a complete, documented mesh repo — the structural-law config,
the reference tables, the page schemas, the templates, the foundation/voice files,
and the repo-local skills. These files are yours to tune to your domain. Run
gtmesh init --list-templates to see the available templates.
Then open the repo in Claude Code and run the repo-local operator skill. The
operator drives the whole build loop conversationally and demand-first: it
interviews you for context, authors foundation/brand.md, validates real keyword
demand first (via gtmesh pull demand --keyword/serp, which needs
AHREFS_API_TOKEN), builds config and reference tables through the
deterministic CLI verbs (gtmesh config set/add, gtmesh reference then
authors the CSVs + gtmesh page validate), runs gtmesh plan, resolves the unresolved
and cannibalization findings, and gtmesh applys — one slice at a time, with a human
approval gate on every judgement call. It keeps the mesh’s
working memory — OPERATOR.md plus
operator/{decisions,journal,tasks} — so a fresh session can reorient from repo
state + gtmesh doctor/gtmesh status.
The steps that follow (pull/plan/apply/promote/write/seal/publish
on the acme example) are exactly the verbs the operator runs on your behalf —
shown here individually so you can see what each does.
- Reads (init): the bundled scaffold template.
- Writes (init): the whole mesh repo (config,
reference/,schemas/,templates/,foundation/,.claude/skills/, …). - Committed: everything
initwrites is committed; it is your project. - Human gate: the operator seeks approval on every judgement call.
Harvest an entity class (optional)
Some classes — a glossary especially — are not derivable from a keyword pull
against your domain axes, because their terms are a cross-cutting taxonomy rather
than children of any one hub. The engine-owned harvest skill figures out
what pages should fill a class: it brainstorms candidates, ranks them against
a demand corpus, validates demand via Ahrefs, scores the cut, writes a curated
term list, and then stops for your review.
gtmesh harvest # lists the configured discovery classes
gtmesh harvest glossary # prints the prompt to run in Claude Code (+ the next steps)gtmesh harvest is the CLI-to-skill bridge: it validates the class and hands
you a prompt. In Claude Code you run that prompt (e.g. harvest glossary),
which writes seeds/glossary.csv (term,target_keyword,family,section,page_type,source
— never metrics). Re-running only proposes new terms, so review stays
small.
- Reads: the
discovery:class config; the demand corpus CSV. - Writes:
seeds/<class>.csv(terms + provenance only). - Human gate: you review and edit the term list — curation is the point.
The term list is the durable, human-reviewed artifact. Volume and difficulty
live in data/raw/ and refresh on cadence — never in the seed file.
Extract keyword data (pull)
gtmesh pull demand --topic slack --dry-run # report the planned pulls, spend no credits
gtmesh pull demand --topic slack # two pulls (matching-terms + questions) → data/raw/keywords/ahrefs/
gtmesh pull demand # all configured seed clusterspull pulls keyword/search signals into the data bag. Raw exports are
immutable and timestamped; plan reads the frozen bag and never re-fetches.
No API budget? gtmesh pull demand --source csv --input export.csv --topic slack
imports a CSV instead.
If you curated a term list with harvest, refresh its volume/difficulty
without expanding it via the overview endpoint:
gtmesh pull demand --source seeds --dry-run # report the classes + term counts, spend nothing
gtmesh pull demand --source seeds # refresh every configured discovery class
gtmesh pull demand --source seeds --discovery-class glossary # just one class- Reads: the
adapters.ahrefsconfig and your seeds/clusters. - Writes: timestamped exports under
data/raw/keywords/ahrefs/. - Committed: yes — the raw exports are diffable and committed.
Preview the diff (plan, optional)
gtmesh plan # prints the plan to stdout (like `terraform plan`); also writes .gtmesh/plan.json
gtmesh plan > plan.md # pipe stdout to a file if you want to keep the rendered plan
gtmesh plan --json | jq . # machine output insteadThe printed plan shows the action summary, the catalogue/create breakdown by
page type, anything needing human review, and an Unresolved list — pages
that matched no sections_map/page_types rule. The engine never guesses: fix
the config rule (or add a seed/override) and re-plan until the unresolved list
is what you expect. Re-planning is free and changes nothing.
- Reads: the config, reference tables, seeds, and the data bag.
- Writes: the human view to stdout; a derived
.gtmesh/plan.json(git-ignored).
Catalogue everything (apply)
gtmesh apply # shows the intended actions, prompts to confirm, then enacts
gtmesh apply --yes # skip the prompt (scripts/CI; required in a non-interactive shell)apply prints what it will do (the same diff as plan) and prompts
Proceed? [y/N] before writing. On this first run every page is catalogued — it
is recorded in the graph, but no page files are created and no built
hashes are set.
- Reads: the same inputs as
plan. - Writes: nodes/edges into
graph/{nodes.jsonl,edges.tsv}. - Committed: yes — commit the graph change.
Promote: choose what to build (the gate)
gtmesh page promote --section glossary --dry-run # preview: which pages would move planned → queued
gtmesh page promote --section glossary # commit the promotionThis is the gate between the two loops. Promotion is intent-based: it selects
pages by field predicates or by the mesh graph, writes only their status, and
builds nothing. It is reversible with demote, and applies by default
(--dry-run previews).
Selectors compose (with AND), e.g. --section, --has, --topic,
--page-type, --slug-prefix, or positional exact slugs; --under <hub-slug>
promotes a hub and everything reachable down the mesh from it. The full selector
table is in the lifecycle reference.
- Reads: the committed graph.
- Writes: the
statusof matched pages. - Human gate: this is the gate — you decide what gets built.
Scaffold the promoted pages (apply)
gtmesh apply # queued pages → content/<slug>/index.yaml (schema skeleton + _brief), status → writingNow apply acts on the promoted pages: it scaffolds each as a bundle folder,
content/<slug>/index.yaml, holding the schema skeleton plus an engine-owned
_brief block (the writer’s assignment). Only slug is deterministic; the
editorial meta fields (metaTitle/title/navTitle/metaDescription) are
scaffolded empty for the writer to fill. Mesh links are not on the page —
they live in the committed graph, so there is no second copy to drift.
- Reads: the registry and the page schemas.
- Writes:
content/<slug>/index.yamlbundles; flips status towriting. - Committed: yes.
Write the body (the article-writer skill)
gtmesh status # the worklist: pages in `writing` await a body (status --json → .writing)This is the only step that uses a model. Invoke the repo-local
article-writer skill. Per page it:
- reads only that page’s
_briefand schema stubs, plustemplates/<type>.mdandfoundation/*(wherebrand.mdcarries the strategic “why”); - fills the
contentfields, and for any image slot authors an art-direction brief (artDirection, noasset:) — it does not generate images (that is step 10); - runs the
humanizerskill, then self-checks withgtmesh page validate <slug>; - invokes the independent
review-gateskill (fail → revise in place, stayswriting; pass → proceed); - on pass, hands the slug back for you to seal — the page stays in
writing.
The CLI and the writer communicate only through the page file (_brief +
content) and the registry status. The CLI never calls a model; the writer
never touches frontmatter or the registry.
- Reads: the page
_brief, schema stubs, templates, and foundation (incl. the strategic “why” inbrand.md). - Writes: the page’s
contentand editorial meta fields — inside that page’s bundle, nothing else. So you can run several writers on different slugs at once. - Human gate: you review the drafts, then seal the batch (
gtmesh page seal <slug…>).
Seal: validate and record (seal)
gtmesh page validate # also checks committed INPUTS: reference tables, discovery seeds
gtmesh page validate <slug> # schema (ajv) + deterministic editorial lint; the writer's self-check
gtmesh page seal <slug> # only if schema-valid AND lint-clean: → review, stamps the 3 built hashesgtmesh page validate first checks your committed input CSVs against the engine’s
column contracts, so a malformed input is caught at the source. A page failing
schema or an editorial lint cannot seal. seal moves the page to review
and stamps built_brief_hash / built_schema_hash / body_hash
— the provenance that makes later diffs precise.
- Reads: the page, its schema, the editorial rules.
- Writes: status →
review; the built hashes in the committed graph.
Publish (publish)
gtmesh page publish <slug> # after the human PR review
gtmesh status --env prod # what's live; render-manifest --env prod for the renderable slug setAfter your PR review, publish moves the sealed page to published. A page
with only image briefs (no asset: yet) publishes fine — the briefs are
schema-valid and the SSG renders placeholders.
- Reads: the registry.
- Writes: status →
published. - Human gate: the PR review precedes publish.
Images: generate and place (the image-director skill)
Image generation is networked and non-deterministic, so it lives in a skill,
never in the CLI. The article-writer emits art-direction briefs (step 7); the
explicit image-director skill turns them into placed files. Invoke it on a
slug — it reads gtmesh.config.yaml’s images: block and
foundation/art-direction.md, generates via the fal.ai MCP, writes the asset
files into the bundle, and adds asset: to each slot. Placing assets is a body
edit, so the page re-enters a light re-seal:
gtmesh page amend <slug> # body changed (assets placed) → needs_update (the skill runs this)
gtmesh page seal <slug> # re-validates with assets present, re-stamps body_hash → review
gtmesh page publish <slug> # → published, now with imagesBoth orderings work: publish copy first and add images later (each pass is an
amend → seal → publish re-entry), or generate before the first publish (one
pass, no re-entry, but slower to first publish).
- Reads: the page’s art-direction briefs;
images:config; art-direction foundation. - Writes: asset files into the bundle;
asset:refs on each slot. - Human gate: the re-seal review before re-publishing.
After the bootstrap
Once the committed graph exists you don’t bootstrap again — you change an input and reconcile. That steady state is the refresh loop, and exactly what each kind of change does to a built page (rewrite, redirect, a zero-rewrite manifest re-projection, …) is the lifecycle reference. For the full command surface, see the CLI reference.