Tuning
GoToMesh never guesses how to classify a keyword. If no rule covers a keyword, the engine surfaces it as unresolved rather than inventing a section for it. So tuning a mesh isn’t a one-time setup — it’s a tight loop driven by what the plan tells you is missing.
This page is the practical guide: the plan says X is unresolved — which knob do I turn? It consolidates the levers in the order you’d reach for them. (For the deeper why behind the shape of the config, see mental models.)
Every generated mesh also ships its own TUNING.md at the repo root — the same guidance,
living next to the files it points at. This page is the hosted copy.
The tuning rhythm
gtmesh pull demand --topic <id> # pull keyword data (once; immutable, re-plan freely)
gtmesh plan # prints the plan to stdout (pipe to a file with `> plan.md` if you like)In the printed plan, read two sections:
- “Catalogue / create — by page type” — is the shape right? Too many of one type?
- “Unresolved — N page(s) need a config rule” — each entry names a keyword with no section/page_type.
Then turn one of the knobs below and re-plan. plan is read-only and free — iterate as much as
you like; nothing is written until you apply. Unresolved is your to-do list: each entry is either
a keyword pattern you haven’t mapped, or junk you should exclude.
How you find a knob
A freshly scaffolded gtmesh.config.yaml holds only the decisions a project should make — the
sections, the domain axes, the routing rules, the demand floor. Every other setting resolves from a
schema default the engine owns, so the file is deliberately not a catalogue of what’s available.
gtmesh config describe is:
gtmesh config describe # the whole menu: every settable key, type, default, what it does
gtmesh config describe taxonomy # open one block, and everything under it
gtmesh config describe images --json # the same tree, machine-readableEach entry carries a docs link to the section that explains the block. Because it renders the same
schema the engine validates against, it can’t drift from what the engine actually reads — and
gtmesh upgrade ships improved explanations to a mesh scaffolded a year ago, which a comment in your
own config file never could.
How you turn a knob
Every knob in gtmesh.config.yaml is written with gtmesh config:
gtmesh config set <path> <value> # a scalar, or a JSON literal for an object/array
gtmesh config add <path> <value> # append to an ORDERED array (sections_map, page_types, …)
gtmesh config unset <path> # remove a value
gtmesh config get <path> # the value in EFFECT, whether you set it or the schema defaults it
gtmesh config list --resolved # the whole effective tree as JSON (plain `list` = the file you wrote)A knob you never turned still has a value: the config file holds your decisions, and every key
you leave out resolves from the schema’s default at load. That’s why get/list --resolved
answer for both, and why gtmesh config clean
can take defaults back out of a file an older scaffold wrote them into, with config_hash unchanged.
Every write preserves your comments, re-validates the whole file, and rolls back if the edit
would make it invalid — the file is left untouched and the error is printed. So a mistake fails
immediately and loudly, instead of surfacing three commands later as a confusing plan. Add
--dry-run to any writer to see the resulting YAML diff without writing.
Paths are dotted, with [N] for an array index — taxonomy.markets[0], sections_map[2].when.match.
Order matters in sections_map and page_types (first match wins), so add takes placement flags:
--at <n>, --before <match>, --after <match>.
For a human-readable view of the structure you’re tuning — sections, domain axes, the page-type table,
how authority flows — run gtmesh explain. It’s generated
from the config, so it’s always in sync.
Let gtmesh doctor find the work for you. Most of the patterns below (unresolved real demand,
page-type imbalance, hub pollution, thin fragments, zero-demand landings, silently-defaulted intent,
committed state gone stale vs config) are exactly what the deterministic linter flags — each finding names the knob to
change. Run it after apply and treat its report as the tuning to-do list. See
After init and doctor.
The knobs, in the order to tune them
1. taxonomy — the structural skeleton
Touch this first because everything else routes against it.
sections— your top-level URL areas (e.g.guides,compare,glossary):gtmesh config add taxonomy.sections glossary.axes— the single-valued grouping axes (category,topic). Used for siblings and axis-based mesh links. You declare the axis names; that is all the engine reads to route and build hubs. Whether an axis lists values decides how its prop on the entity nodes is validated:- Open (
gtmesh config set taxonomy.axes.category '[]') — an entity-valued axis. Its values are entity slugs, so the vocabulary already lives on the entity nodes in the graph and is validated against them. Leave it empty — don’t copy your category or manufacturer list here; a value list would be a second copy of your catalog that silently drifts out of sync (a mistypedmanufaturervalue is caught as a dangling entity reference, exactly like a badbrand). - Closed (
topic: [education, comparison, …]) — a genuine, fixed theme enum that isn’t a domain entity, so its vocabulary lives here rather than on the entity nodes. List its values;gtmesh explainshows them. A closed axis’s values are a display vocabulary, not entity references.
- Open (
intents— order matters. Classify picks the first intent present on a keyword, so put the most discriminating intents first (gtmesh config set taxonomy.intents '["transactional","commercial","branded","informational"]').informationalis near-universal — put it last, or it swamps the topic cluster. To override the global order for one section, pin it withgtmesh config set classification.section_intent.glossary informational, so a “what is X” page isn’t taggedbrandedjust because it names a brand. The pin also applies when a page carries no intent signal at all — a no-signal glossary term reads as its pinnedinformational, not the first-declared intent — so pin the sections whose intent you know regardless of the keyword tags. The global untagged default is the first-declared intent, which is also the priority head — so the intent you want to try first doubles as the fallback for a keyword with no tag. If those should differ (saytransactionalleads priority, but an untagged keyword should default toinformational), rungtmesh config set classification.default_intent informational— it becomes the global fallback for a no-tag, no-pin keyword without touching the priority order. Unset, behaviour is unchanged. It never overrides an observed intent or a section pin. Declaring it also quiets thedefaulted-intentdoctor check (below) — the default becomes a deliberate choice, not a silent guess. A defaulted intent is also the usual root of a phantomtopic-axis cluster — a spuriouspillar-candidatewarning whose candidate terms look unrelated; pinning the section’s intent clears it at the source (see thepillar-candidateguidance below).identity.anchor— page URL reads off the wrong keyword?head(default) names the slug after the page’s head keyword;parent_topicuses the source cluster label (gtmesh config set identity.anchor parent_topic). A slug is frozen once committed (a head-flip on re-pull won’t churn the URL) — to deliberately move a URL, use arenamegroup-edit (it pins the new slug, keeps the page, and emits a 301).- Junk extra page from one stray keyword? Run
gtmesh config set classification.min_group_members 2to fold a below-threshold typed sibling back into the parent’s dominant page; real fan-out with demand is kept. A would-be-folded sibling is rescued if it clears any ofmin_group_volume_pct(share of the parent’s volume),min_group_volume_abs(an absolute volume floor — portable across markets/sources), ormin_group_candidate_pct(share of the leftover siblings’ volume, which a dominant brand hub can’t swamp). When the dominant page is a hub, a non-surviving stray is dropped rather than folded, so it can’t pollute the hub with an unrelated intent. - A swarm of thin FAQ pages from the
questionspull? Route the long tail into answer blocks on the parent page instead of a page each. Setclassification.question_page_min_volume(the mint-vs-fold bar) — one knob, no per-type flag. Aquestions-pull group below the bar folds its question text onto thequestions[]of a page that owns the topic anywhere in the mesh — matched by the sameparent_topicor by word-boundary containment (“what is pump cavitation” → thecavitationglossary page), so questions reach existing content pages too — chosen by intent: an informational question lands on the content page, a commercial one on the commercial page (intent resolves via the samesection_intent→taxonomy.intents→ default chain a page uses, so route question phrasings to intent-appropriate sections insections_map). The writer reads them (demand-ordered) in the page’s_briefand answers them in the body +faq[]. A group at/above the bar keeps minting its own page. Leavequestion_page_min_volumeunset for the old behavior (every question mints its own page) —gtmesh config set classification.question_page_min_volume 30. See Feeding question demand into the brief.
Your domain’s entities (brands, products, categories), authored into the committed graph — each
a node whose labels are its class and domain axes, and whose props carry its facts and facet
memberships:
gtmesh graph upsert acme-x100 \
--label product \
--prop name="Acme X100" \
--prop category=sensors --prop brand=acme \
--prop source_url="https://acme.example/x100"This is load-bearing for the mesh: entity resolution sets a page’s category cluster, and a
spoke links up to the hub for the same entity (e.g. a glossary page about X → the X hub). Author
one node per entity, or up-links won’t form. (Read a node back with gtmesh graph get acme-x100;
gtmesh page validate checks the contract.)
When a keyword names more than one entity, the winner is chosen by
classification.entity_kind_priority (highest class first), then longest match — never by
authoring order. So set that priority for your domain and author the nodes in whatever order:
gtmesh config set classification.entity_kind_priority '["brand","type","material"]'An entity contributes a value to every taxonomy.axes axis it carries a prop for — so a
catalogue can give an entity a brand prop (and declare a brand domain axis), letting a
product page roll up by brand as well as category.
Dual-parent hygiene (doctor). When a page should ladder up on two axes (a product to its
category and its manufacturer hub), two silent failure modes hide each other — and doctor
catches both. dropped-axis fires when the page’s entity declares two axes but the page carries only
one, because a less-specific class won the entity_kind_priority tiebreak and dropped the other axis
(fix the priority). Once both axes are present, reachable-hub-unlinked fires when the page shares an
axis with an owner-hub it doesn’t link up to — add a hub_of(<axis>) rule to link_rules for that
page type. Run them in that order: fix the axis, then add the link rule.
An entity’s aliases prop (pipe-separated) lists its other written forms — different spellings,
transliterations, a foreign-script name. They’re used for entity resolution and (with entity-aware
classification, below) for brand stripping, so «дипсик», deepseek, and DeepSeek all resolve to
one entity.
Entity facts reach the writer. When a page resolves to a known entity, that entity’s grounding
fields (name, source_url, notes, aliases) are folded into the brief — surfaced on the page
as _brief.facts and covered by brief_hash, so changing a fact rewrites the page (a correctness
fix; it used to be a silent no-op). The article-writer reads those facts straight off _brief.facts,
and if a source_url is present it may fetch the URL as a primary source for real product facts.
(The engine only passes the facts/URL as data; the fetch happens in the writer, the model step. Other
entity props — facet axes like category — become cluster memberships, not brief facts.)
Entity-aware classification (opt-in)
Off by default. Turn it on when one entity has many keyword spellings/phrasings that should
consolidate into a single hub instead of minting a page per variant — the case that prompted it is
Russian brand terms (Cyrillic + Latin spellings of the same tool). After
gtmesh config set classification.entity_aware true, a keyword that contains a known entity node is
regrouped under the entity’s canonical slug, the brand forms are stripped, and the leftover
remainder is classified:
- Empty remainder (the keyword was only the brand, or brand + a generic descriptor) → the entity’s hub. So «дипсик» + «deepseek» + «дипсик нейросеть» collapse into one hub, its head = the highest-volume variant.
- Intent remainder → a sub-page identified by a
discriminator(seethen.discriminatorin §3, else the remainder’s first significant token). So «дипсик скачать» surfaces on its own page, not hidden under the brand head — and «скачать» / «скачать на пк» share oneskachatpage.
gtmesh config set classification.entity_aware true
# stripped as noise alongside the brand (not an intent):
gtmesh config add classification.generic_descriptors нейросеть
# sections whose pages nest UNDER the entity hub, at /{hub_root}/{entity}/{kind}/:
gtmesh config add classification.nested_sections utility
# …maps entity kind → URL root, e.g. «дипсик скачать» → /neyroseti/deepseek/skachat:
gtmesh config set classification.hub_root_by_kind.neuroset neyroseti
# page types anchored to /{section}/{entity} (one per entity):
gtmesh config add classification.entity_index_types model-indexgeneric_descriptors— words stripped alongside the brand, so «дипсик нейросеть» reads as just the brand (routes to the hub), not a phantom «нейросеть» sub-page.nested_sections+hub_root_by_kind— a logical section likeutilityisn’t a real URL root; its entity pages nest under the hub. A page in a nested section that resolves to an entity (with a discriminator) gets the slug/{hub_root}/{entity}/{kind}/;hub_root_by_kindmaps the entity’s kind (theclasscolumn) → that root. Unmapped kind → the flat/{section}/…shape.entity_index_types— page types that are one per entity (e.g. amodel-indexlisting an entity’s models). Their slug is anchored to the entity (/{section}/{entity}), so «claude модели» and «модели claude» don’t mint two pages.
Brand stripping matches multi-word and hyphenated forms too (name Chat GPT, slug chat-gpt),
so a pure multi-word brand still lands on the hub. Everything here is opt-in — with the flag off,
classification is unchanged.
Facets — many-to-many memberships (taxonomy.facets)
A taxonomy.axes axis is single-valued and authority-bearing: a product has one category, one
brand, and rolls up to each of those hubs (that single-ness is what lets authority converge). But
some memberships are many-to-many — one product can serve several applications across several
industries. Model those as facets, the multi-value, non-authority sibling of a cluster
axis:
# single-value → authority up-link; open (each value is an entity)
gtmesh config set taxonomy.axes '{"category":[],"brand":[]}'
# multi-value (pipe-separated), NO authority; open → entity-slug references
gtmesh config set taxonomy.facets '{"application":[],"industry":[]}'The value lives in a prop on the entity node — for a facet it’s pipe-separated (many memberships); for a domain axis it’s a single slug. Either way each value is itself an entity (a landing page in its own right), so the axis stays open and its vocabulary lives on the entity nodes, never restated in config. Author the landing entities, then the product that references them:
# the landing entities (each a node of its own class)
gtmesh graph upsert sensors --label category
gtmesh graph upsert monitoring --label application
gtmesh graph upsert manufacturing --label industry
gtmesh graph upsert acme --label brand
# the product — single-valued cluster props + pipe-separated facet props
gtmesh graph upsert acme-x100 --label product --prop name="Acme X100" \
--prop category=sensors --prop brand=acme \
--prop 'application=monitoring|automation|diagnostics' \
--prop 'industry=manufacturing|energy'A facet never becomes an up-link (a product pointing up to five applications would dilute
authority convergence), so it doesn’t touch the mesh graph. Instead it’s carried page data your SSG
reads to build the application → member products index at render time. gtmesh page validate (and
doctor) cross-checks that every facet token names a real entity — so a mistyped monitorng is
caught before it becomes a dead facet link, exactly like a dangling brand.
Upgrading an existing mesh. This change is additive and opt-in — nothing breaks if you do
nothing. A domain axis that still lists values keeps validating exactly as before (its list is a
display vocabulary, never treated as entity references). To adopt the recommendation for an
entity-valued axis (category, manufacturer, collection, …), empty its value list —
gtmesh config set taxonomy.axes.category '[]' — and make sure every value of that prop on your
entity nodes names a real entity. Once open, the axis is validated against the entity nodes (the
single source): gtmesh page validate flags a category or manufacturer value that isn’t a known entity, so
drop-and-drift is caught instead of silently diverging. Leave a genuine theme enum (topic, say)
closed. gtmesh.config.yaml is project-owned, so gtmesh upgrade never rewrites your axis values —
one config set per axis is the whole migration.
3. sections_map — route a keyword to a section
Ordered when → then rules: a keyword pattern → a section. First match wins. A match value
is a case-insensitive substring, or an anchored /regex/ for precision. Anything matching no
rule → unresolved. This is the knob you turn most when working down the unresolved list — add
rules until what’s left is only genuine junk. Because first-match wins, place each rule
deliberately:
# a glossary rule, ahead of the broader "reviews" rule already in the map
gtmesh config add sections_map \
'{"when":{"match":"what is"},"then":{"section":"glossary"}}' --before '"reviews"'A rule may also set then.discriminator — the kind it pins for a matched keyword under
entity-aware classification, so synonyms collapse to one sub-page (e.g. «скачать» / «установить» /
«на пк» → skachat). Absent a rule, the discriminator falls back to the remainder’s first
significant token.
Routing a keyword that has no phrase to match — when: { intent: … }
match routes by phrasing, which leaves a hole: a bare type-noun ("peristaltic pump") carries no
what is / how to / vs pattern, so it falls through every rule and becomes a sectionless, typeless,
unbuildable page. A when clause can also test intent — membership in the keyword’s own intent tags —
which routes exactly those:
sections_map:
- { when: { match: " vs " }, then: { section: compare } }
- { when: { match: "what is" }, then: { section: glossary } }
- { when: { match: "how to" }, then: { section: guides } }
# LAST — a per-intent default for anything the phrase rules didn't catch:
- { when: { intent: informational }, then: { section: glossary } }gtmesh config add appends by default, which is exactly where that last rule belongs:
gtmesh config add sections_map '{"when":{"intent":"informational"},"then":{"section":"glossary"}}'Because first-match wins, placing it last makes it a default, not an override — the phrase rules above
still win, so "peristaltic pump vs diaphragm pump" still routes to compare. Keep it intent-scoped rather
than an unconditional when: {} catch-all: a commercial bare noun ("peristaltic pump price") shouldn’t
land in your glossary, and leaving it unresolved is the signal that it needs its own rule.
intent composes with match (all clauses must hold), and it tests the keyword’s raw intent tags from
the demand pull — deliberately not the resolved intent, since classification.section_intent derives intent
from the section and the two would otherwise be circular.
Untagged keywords match no intent clause. Providers leave intent blank on much of the long tail (on a
niche B2B mesh, the questions pull can come back ~14% tagged). Those keywords fall through an intent rule
the same way they fall through a phrase rule — and now show up in the Unresolved list asking for one.
4. reference/signals.csv
Keyword pattern → role (pillar / hub / sub-hub / spoke). Reserve hub and pillar
for the pages a topic genuinely converges on — let the long tail be feeders and spokes. (Apex pages
usually come from seed pages (gtmesh page add, below), not from keyword signals.) The role is derived
before page_type, so a page_type can require when: { role: hub }.
This file is how the middle feeder layer gets populated. A keyword that matches no signals rule
falls to the default role: spoke. So an empty signals.csv gives you a barbell: hand-seeded and
entity hubs as apexes, everything else a spoke, and nothing in between. Add rules that appoint your
feeder query shapes to lift them out of the spoke default:
signal,match,role,note
comparison," vs ",sub-hub,"X vs Y" comparisons feed the hub
best,"best ",sub-hub,buying-guide / best-of feeders
alternative,alternative,sub-hub,"X alternatives" feedersFirst match wins (like sections_map); the match value is a case-insensitive substring or an
anchored /regex/. Entity-aware classification can also mint hubs directly (a brand-only keyword
→ its hub), so you don’t need a signals rule for those.
5. page_types — bind a page to a template + schema
Each entry: a when (match by section, role, and/or entity_kind) → a template +
schema. Ordered; first match wins. For any new type, author its templates/<type>.md (prose
theory) + schemas/<type>.schema.yaml (structure). Use schema_variants + variant_when for
variant-by-peer.
Type a hub by its entity’s class with when: { entity_kind: … } (#148). A demand-minted hub
(entity-aware classification) carries no section, so without this every hub collapses to one generic
entity-hub. Routing on the entity node’s class label gives each its own type — and,
crucially, makes its identity match a hand-authored typed seed, so the demand folds into the
seed hub instead of minting a separate page:
page_types:
- id: manufacturer-hub
role: hub
when: { role: hub, entity_kind: manufacturer } # Grundfos (class manufacturer) → manufacturer-hub
template: manufacturer-hub.md
schema: manufacturer-hub.schema.yaml
- id: industry-hub
role: hub
when: { role: hub, entity_kind: industry } # the entity's `kind` TYPES the hub (#148)
template: industry-hub.md
schema: industry-hub.schema.yaml
- id: entity-hub # fallback — keep LAST (matches any hub)
role: hub
when: { role: hub }
template: entity-hub.md
schema: entity-hub.schema.yamlOrder the class-specific types before the generic entity-hub — which is what add’s
--before gives you:
gtmesh config add page_types \
'{"id":"industry-hub","role":"hub","when":{"role":"hub","entity_kind":"industry"},
"template":"industry-hub.md","schema":"industry-hub.schema.yaml"}' \
--before '"entity-hub"'entity_kind works on spokes too, not just hubs. (Seed pages already type freely — they author
page_type directly; this closes the gap for demand-minted hubs.)
The one place hand-editing still wins. page_types entries are the largest nested blocks in the
config, and a whole entry as a single-line JSON literal is hard to read and harder to review in a
diff. For a block this size, opening gtmesh.config.yaml and writing the YAML by hand is the clearer
path — then run gtmesh plan (or gtmesh config get page_types), which parses and validates the
file, to confirm it still loads. Reach for gtmesh config for everything smaller, where it’s both
quicker and safer.
The per-type schemas (schemas/<type>.schema.yaml) are yours — extend or add freely;
gtmesh upgrade never touches them. They share a base, schemas/common.schema.yaml, which is
engine-owned and refreshed on upgrade (if you’ve edited it, the new version lands as
common.schema.yaml.new to merge).
6. scope: + adapters.ahrefs.exclude_substrings — filter the noise
Filter out what you’ll never build pages for — navigational / brand-asset / off-topic queries
(login, logo, retailer names, sizing charts…):
gtmesh config add adapters.ahrefs.exclude_substrings login # one entry per phrase
gtmesh config set 'scope.include[0].min_volume' 50 # the global demand floor
gtmesh config set scope.sections.glossary.min_volume 0 # …or per sectionThis is the two-layer noise model, and it’s
why there’s no scope.drop — the two layers already cover both cases:
exclude_substrings— permanent noise (homonyms, consumer terms on a B2B mesh). Filtered at pull, so it never entersdata/raw/or the committed graph at all. Saves API credits and is the right home for junk you never want to see again. Each entry matches as a case-insensitive literal phrase (Ahrefsiphrase_match), not a loose word-OR — so a multi-word exclude likepump streetdrops only keywords containing that exact phrase, and a single word likesumpwon’t catchresumption. Use one entry per phrase you want gone. Matching is literal, so an acronym or synonym of an excluded term slips through — excludingdiaphragmwon’t catchaodd(air-operated double diaphragm). List each spelling and acronym you want gone as its own entry.scope:— scope. Parks a term tobacklogat plan. The term still exists; it just isn’t built, so it’s recoverable later.
The volume floor gates only the raw keyword pull (source: keyword). Curated inputs bypass it:
source-less seed pages and human-reviewed discovery
terms (seeds/<class>.csv) are deliberate
inclusions, so a null- or low-volume brand-model or editorial term stays buildable instead of parking
— you already vetted it; the floor is for the open pull, not for curation.
Either way, scoped-out terms drop off the unresolved list. Both layers are honoured by any
gtmesh apply that recomputes from current inputs — excluded terms stay gone, scoped terms come
back as backlog.
Keep the two floors in step. scope declares what is buildable; adapters.<source>.volume_floor
decides what is fetched. If the pull floor sits at or above your lowest scope floor, everything
between them is declared open and never arrives — and the gap looks like “no demand exists” when it
was simply never asked for. A section floor of 0 behind a pull floor of 100 is the common shape:
gtmesh config set scope.sections.glossary.min_volume 0 # declared buildable at any volume…
gtmesh config set adapters.ahrefs.volume_floor 100 # …but nothing below 100 is ever pulled
gtmesh config set adapters.ahrefs.pull_overrides.questions.volume_floor 10 # a per-pull override that reaches lower clears itgtmesh doctor flags this as shadowed-scope-floor, naming both floors. Lower the pull floor to
reach the demand you’ve declared open to, or raise the scope floor to what the pull actually
delivers.
7. Seed pages (gtmesh page add)
Source-less pages — apex/pillar/conversion hubs with no demand behind them. Your apex hubs are
authored this way (give each an intent so it clusters). These are the destinations the keyword
long tail links up to. page add writes a source: seed node straight into the committed graph.
Flags: --slug, --keyword, --section, --type, --role, --intent, --parent-topic, --discriminator, --kind, --label, --prop.
Appointing a pillar (or hub) — you do not need keyword demand. A pillar’s value is structural:
it’s the convergence vertex a sub-cluster’s authority pools onto. So you appoint one with an
explicit --role, and it needs no search volume of its own:
gtmesh page add \
--slug /guides/positive-displacement-pumps \
--keyword "positive displacement pump" \
--section guides --type guide \
--role pillar --intent informational--role— set it explicitly:pillar(a content-convergence vertex — informational feeders pool onto it) orhub(a navigational/commercial vertex). Omit it and the page is a member of its--parent-topicrather than that topic’s apex.--keywordis the page’s editorial head term, not a demand claim. A seed node ships off-calendar (priority: null) because it’ssource: seed— regardless of its keyword; the keyword just names the page’s identity/title. An apex seed owns its--parent-topic, and that topic is what its feeders route up to.- Two routes, by whether the page has demand.
reference/signals.csvpromotes a page torole: pillarby matching a keyword — so it only works when the page has search volume.gtmesh page addis the demand-free route: appoint any page (including a vol-0 curated guide) as a pillar directly. Reach for signals when a real head term is the pillar; reach forpage addwhen the pillar is a curated page with little or no volume of its own.
Finding the right pillar. When a topic cluster has feeders but no pillar, gtmesh doctor
(pillar-candidate) flags it and lists candidate lead-page terms by demand — you appoint one.
Run gtmesh config set classification.pillar_head_patterns '["what is","definition"]' to float
definitional heads to the top of that list; the engine never picks for you (choosing a topic’s lead
page is an editorial/SERP call).
Not every pillar-candidate wants a pillar. A topic-axis cluster whose candidate terms look
unrelated is usually a defaulted-intent artifact — zero-demand pages whose intent fell to the
global default, landing them on a shared topic value none of them is really about.
The finding’s own hint says as much: check the defaulted-intent / zero-demand-landings findings
first. The real fix is to pin the intent at the root — classification.section_intent /
default_intent (§1 above), e.g. section_intent: { guides: informational } flips a how-to guide out
of the phantom cluster and clears the warning — or to prune the zero-demand pages. Appointing a
pillar for a phantom cluster is the wrong fix.
The apex owns the topic head. A topic’s head keyword belongs to its apex (the hub or pillar);
every other page in the cluster targets a longer-tail variant and routes authority up to it. Two
gtmesh doctor checks guard this:
no-apex-page-claims-head— a non-apex page (a member, or an orphan under another topic) whoseprimary_keywordequals a topic’s head is cannibalizing the apex: two pages splitting one head’s rank equity. Fix by retargeting that page’s keyword to a longer-tail variant (viareference/signals.csvor the source keyword), or — if it should lead the topic — appoint it the apex withgtmesh page add --role hub|pillar.apex-keyword-equals-topic-head— a topic owned by two apexes (a hub and a pillar) whoseprimary_keywords disagree.applypicks one apex’s keyword for the topic head and silently drops the other’s, so the head is decided arbitrarily. Converge both apexes on one head, or drop the extra apex.
Both are warnings (they never gate apply) and pass by construction on a healthy mesh — the head is
set from the apex, so a single-apex topic can’t violate either.
8. scoring + transforms — rank what to build first
scoring.formularanks pages intopriority;scoring.exemptmarks off-calendar pages (priority = null);commercial_bonusweights by CPC — a condition map, not a single number:(A SERP-classifiedgtmesh config set scoring.formula 'total_vol / (min_diff + 5) * commercial_bonus' gtmesh config set scoring.commercial_bonus '{">=30 cpc": 1.4, "default": 1.0}'commercial-layer landing is exempt — it ships by business priority, not volume, so money pages aren’t buried behind volume-ranked blog content. Inert for a non-commercial mesh.)scoring.topic_opportunity/scoring.topic_effort— the formulas that derive a topic’sopportunity(value at stake) andeffort(cost to win) from its head’s demand. Same whitelisted arithmetic asscoring.formula, over the varstotal_vol,difficulty(KD), andcpc. Defaults:opportunity: "total_vol * cpc"(a traffic-value proxy) andeffort: "difficulty". These drivegtmesh topic list’s opportunity-first ordering. Domain-free — the engine bakes no business numbers; tune the formula to your economics withgtmesh config set scoring.topic_opportunity 'total_vol * cpc'.scoring.topic_selection— the selection scoregtmesh topic candidatesranks by (commercial ∩ winnable). Same arithmetic, overopportunity,effort,total_vol,difficulty,cpc, and the three winnability vars:winnability(the combined 0..1),head_winnability(can we take the head term directly?) andtail_winnability(is there a low-KD uncovered way in?). An unknown side reads as the neutral1. Default:"opportunity * winnability"— the value at stake, discounted by how winnable the topic is, wherewinnability = max(head, tail)so a hard head is a horizon rather than a veto (see Winning a topic). The commercial half already rides insideopportunity, since its default istotal_vol * cpc. Pair it withgtmesh config set doctor.min_winnability 0.2(0..1, default0= off), which gives theholdverdict to a topic with no way in from either side.transforms/*.ts— pure, row-local derivations for deterministic project columns (e.g. a computed score band). None ship by default. Titles are not here —metaTitle/title/navTitle/metaDescriptionare writer-authored editorial meta (config.authored_meta); the LLM writes good titles, with rules infoundation/editorial.md. Onlyslugis deterministic.
9. aeo — the citability gate
The engine-owned review-gate skill scores every draft for AI-answer-engine citability. You tune
the numbers here — dimensions[] (weights + per-dimension threshold), overall_threshold,
and the answer_block word range — never by editing the skill (upgrade keeps it current):
gtmesh config set aeo.overall_threshold 80
gtmesh config set aeo.answer_block.max_words 70The voice the gate pulls toward is foundation/voice.md; the banned words are
reference/editorial-rules.yaml. The gate and the humanizer skill both read those, so retune
voice there, not in the skills.
10. Images — foundation/art-direction.md + images
If a page type has image slots (e.g. hero, gallery), the article-writer authors art-direction
briefs and the engine-owned image-director skill generates + places the files (an explicit
step).
- Tune the look by editing
foundation/art-direction.md(registers, prompts, negative prompt, per-type briefs) — it’s prose, so it’s yours to write directly. - Tune the knobs with
gtmesh config:gtmesh config set images.model fal-ai/nano-banana-2 gtmesh config set images.registers.diagram.ratio '"3:2"' gtmesh config set images.registers.lifestyle.settings.safety_tolerance 5
The register names in the two files must match. You never edit the skill.
Rubrics — rubrics
For rubric-bearing page types (review / comparison / best-for), rubrics declares the scored
dimensions the writer evaluates into the page’s rubric. Retune the axes here, never by
editing the template. Keep them concrete and few (3–6) so pages stay comparable; a page type with
no entry scores no rubric.
gtmesh config set rubrics.review '["ease of setup","reliability","support quality","value"]'Then run the loop
Once plan looks right:
gtmesh apply # catalogue everything (previews + asks to confirm)
gtmesh page promote --section <x> # choose a batch to build (sets status; --dry-run previews; demote reverses)
gtmesh apply # scaffold the promoted pages → writing
gtmesh status # the worklist (pages awaiting a body)
# → the article-writer skill fills content/<slug>/index.yaml, runs `gtmesh page validate`, humanizer, review-gate
gtmesh page seal <slug> # writing → review (schema-valid + lint-clean only)
gtmesh page publish <slug> # → publishedFor what each of those steps and statuses means in full, see lifecycle & reconcile.
Guardrails
- Write config through
gtmesh config—set/add/unsetpreserve your comments, re-validate, and roll back a bad edit. The one place hand-editing still reads better is a wholepage_typesentry (§5). - Never hand-edit the committed graph (
graph/nodes.jsonl,graph/edges.tsv) —apply/seal/promote/ lifecycle are its only writers. - The pipeline is deterministic and LLM-free except the article-writer step.
- Structural mesh links render as nav, not prose — they live in the graph, not the page YAML. Don’t write up/sibling links into the body. A relink is a deterministic manifest re-projection — the page file is untouched.
- Changing a content-determining field →
rewrite(the writer reruns the body); changing the render projection (slug, mesh links, cards) is a zero-rewrite re-projection ofsite.manifest.json(deterministic, no writer). That’s why the body is governed bybrief_hashand the projection re-derives for free.