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) and a Slack keyword pull.
The one idea: the registry is desired state
Everything orbits the committed registry, registry/pages.csv — 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, restamp), 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 add -g gtmesh # (or, from source: pnpm install && pnpm build && pnpm link --global)
export AHREFS_API_TOKEN=… # required for `extract --source ahrefs`; never commit itThe steps
Discover the project shape (optional)
To start tailored to your business instead of the generic demo, run project discovery first. Make an empty directory and run:
mkdir mybrand && cd mybrand
gtmesh discover "what you sell/publish, audience, market, URL"
# → installs the `discovery` skill into .claude/skills/ and prints a promptgtmesh discover is LLM-free — it just installs the skill and hands you a
prompt. Open the directory in Claude Code and paste the prompt. The
discovery skill interviews you, pulls a demand-validated information
architecture (sections, clusters, the page types it needs, harvest classes,
clustered starter terms), and writes gtmesh-discovery.yaml, then stops.
- Reads: your answers in the interview; live demand signals.
- Writes:
gtmesh-discovery.yaml(you review and edit it). - Human gate: you review the discovery file before step 1.
Skip this step to start from the generic Acme demo instead. The contract for that file is documented in the discovery contract.
Scaffold the repo (init)
mkdir acme-integrations && cd acme-integrations
gtmesh init # writes the documented mesh repo (config, reference, schemas, skills…)
# …or, tailored from step 0:
gtmesh init --from-discovery gtmesh-discovery.yamlinit 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.
With --from-discovery, init scaffolds the full library, then overlays
the discovery shape onto gtmesh.config.yaml (preserving the educational
comments): your project, taxonomy, section routing, the selected page types,
and the harvest classes; it writes each cluster’s starter terms to
seeds/<class>.csv, and templates your project identity into the README. It
also clears the demo’s worked-example data so a tailored mesh does not inherit
Acme’s. The full overlay rules are in
the discovery contract.
- Reads: the bundled scaffold template; optionally
gtmesh-discovery.yaml. - Writes: the whole mesh repo (config,
reference/,schemas/,templates/,foundation/,.claude/skills/, …). - Committed: everything
initwrites is committed; it is your project.
Harvest an entity class (optional)
Some classes — a glossary especially — are not derivable from a keyword pull
against your clusters, 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 (extract)
gtmesh extract --cluster slack --dry-run # report the planned pulls, spend no credits
gtmesh extract --cluster slack # two pulls (matching-terms + questions) → data/raw/keywords/ahrefs/
gtmesh extract # all configured seed clustersextract 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 extract --source csv --input export.csv --cluster 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 extract --source seeds --dry-run # report the classes + term counts, spend nothing
gtmesh extract --source seeds # refresh every configured discovery class
gtmesh extract --source seeds --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 row is catalogued — it
is recorded in the registry, but no page files are created and no built
hashes are set.
- Reads: the same inputs as
plan. - Writes: rows into
registry/pages.csv. - Committed: yes — commit the registry change.
Promote: choose what to build (the gate)
gtmesh promote --section glossary --dry-run # preview: which rows would move planned → queued
gtmesh promote --section glossary # commit the promotionThis is the gate between the two loops. Promotion is intent-based: it selects
rows by registry fields 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, --cluster, --tier,
--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 registry.
- Writes: the
statuscolumn of matched rows. - Human gate: this is the gate — you decide what gets built.
Scaffold the promoted rows (apply)
gtmesh apply # queued rows → content/<slug>/index.yaml (schema skeleton + _brief), status → writingNow apply acts on the promoted rows: 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 registry, 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>.md,foundation/*, andstrategy.md; - 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 validate <slug>; - invokes the independent
review-gateskill (fail → revise in place, stayswriting; pass → proceed); - on pass, prompts you: review the draft, or auto-seal?
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, foundation, strategy. - Writes: the page’s
contentand editorial meta fields. - Human gate: you review the draft (or opt into auto-seal).
Seal: validate and record (seal)
gtmesh validate # also checks committed INPUTS: reference tables, seed-pages, discovery seeds
gtmesh validate <slug> # schema (ajv) + deterministic editorial lint; the writer's self-check
gtmesh seal <slug> # only if schema-valid AND lint-clean: → review, stamps the 3 built hashesgtmesh 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_content_hash / built_projection_hash / built_schema_hash
— the provenance that makes later diffs precise.
- Reads: the page, its schema, the editorial rules.
- Writes: status →
review; the three built hashes in the registry.
Publish (publish)
gtmesh 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 amend <slug> # body changed (assets placed) → needs_update (the skill runs this)
gtmesh seal <slug> # re-validates with assets present, re-stamps body_hash → review
gtmesh 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 registry 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, restamp, redirect, prune, …) is the lifecycle reference. For the full command surface, see the CLI reference.