Skip to Content

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-readable

Each 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 mistyped manufaturer value is caught as a dangling entity reference, exactly like a bad brand).
    • 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 explain shows them. A closed axis’s values are a display vocabulary, not entity references.
  • intentsorder 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"]'). informational is near-universal — put it last, or it swamps the topic cluster. To override the global order for one section, pin it with gtmesh config set classification.section_intent.glossary informational, so a “what is X” page isn’t tagged branded just 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 pinned informational, 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 (say transactional leads priority, but an untagged keyword should default to informational), run gtmesh 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 the defaulted-intent doctor check (below) — the default becomes a deliberate choice, not a silent guess. A defaulted intent is also the usual root of a phantom topic-axis cluster — a spurious pillar-candidate warning whose candidate terms look unrelated; pinning the section’s intent clears it at the source (see the pillar-candidate guidance below).
  • identity.anchorpage URL reads off the wrong keyword? head (default) names the slug after the page’s head keyword; parent_topic uses 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 a rename group-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 2 to 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 of min_group_volume_pct (share of the parent’s volume), min_group_volume_abs (an absolute volume floor — portable across markets/sources), or min_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 questions pull? Route the long tail into answer blocks on the parent page instead of a page each. Set classification.question_page_min_volume (the mint-vs-fold bar) — one knob, no per-type flag. A questions-pull group below the bar folds its question text onto the questions[] of a page that owns the topic anywhere in the mesh — matched by the same parent_topic or by word-boundary containment (“what is pump cavitation” → the cavitation glossary 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 same section_intenttaxonomy.intents → default chain a page uses, so route question phrasings to intent-appropriate sections in sections_map). The writer reads them (demand-ordered) in the page’s _brief and answers them in the body + faq[]. A group at/above the bar keeps minting its own page. Leave question_page_min_volume unset 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 (see then.discriminator in §3, else the remainder’s first significant token). So «дипсик скачать» surfaces on its own page, not hidden under the brand head — and «скачать» / «скачать на пк» share one skachat page.
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-index
  • generic_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 like utility isn’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_kind maps the entity’s kind (the class column) → that root. Unmapped kind → the flat /{section}/… shape.
  • entity_index_types — page types that are one per entity (e.g. a model-index listing 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" feeders

First 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.yaml

Order 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 section

This is the two-layer noise model, and it’s why there’s no scope.drop — the two layers already cover both cases:

  • exclude_substringspermanent noise (homonyms, consumer terms on a B2B mesh). Filtered at pull, so it never enters data/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 (Ahrefs iphrase_match), not a loose word-OR — so a multi-word exclude like pump street drops only keywords containing that exact phrase, and a single word like sump won’t catch resumption. Use one entry per phrase you want gone. Matching is literal, so an acronym or synonym of an excluded term slips through — excluding diaphragm won’t catch aodd (air-operated double diaphragm). List each spelling and acronym you want gone as its own entry.
  • scope:scope. Parks a term to backlog at 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 it

gtmesh 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) or hub (a navigational/commercial vertex). Omit it and the page is a member of its --parent-topic rather than that topic’s apex.
  • --keyword is the page’s editorial head term, not a demand claim. A seed node ships off-calendar (priority: null) because it’s source: 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.csv promotes a page to role: pillar by matching a keyword — so it only works when the page has search volume. gtmesh page add is 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 for page add when 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) whose primary_keyword equals 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 (via reference/signals.csv or the source keyword), or — if it should lead the topic — appoint it the apex with gtmesh page add --role hub|pillar.
  • apex-keyword-equals-topic-head — a topic owned by two apexes (a hub and a pillar) whose primary_keywords disagree. apply picks 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.formula ranks pages into priority; scoring.exempt marks off-calendar pages (priority = null); commercial_bonus weights by CPC — a condition map, not a single number:
    gtmesh config set scoring.formula 'total_vol / (min_diff + 5) * commercial_bonus' gtmesh config set scoring.commercial_bonus '{">=30 cpc": 1.4, "default": 1.0}'
    (A SERP-classified 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’s opportunity (value at stake) and effort (cost to win) from its head’s demand. Same whitelisted arithmetic as scoring.formula, over the vars total_vol, difficulty (KD), and cpc. Defaults: opportunity: "total_vol * cpc" (a traffic-value proxy) and effort: "difficulty". These drive gtmesh topic list’s opportunity-first ordering. Domain-free — the engine bakes no business numbers; tune the formula to your economics with gtmesh config set scoring.topic_opportunity 'total_vol * cpc'.
  • scoring.topic_selection — the selection score gtmesh topic candidates ranks by (commercial ∩ winnable). Same arithmetic, over opportunity, effort, total_vol, difficulty, cpc, and the three winnability vars: winnability (the combined 0..1), head_winnability (can we take the head term directly?) and tail_winnability (is there a low-KD uncovered way in?). An unknown side reads as the neutral 1. Default: "opportunity * winnability" — the value at stake, discounted by how winnable the topic is, where winnability = max(head, tail) so a hard head is a horizon rather than a veto (see Winning a topic). The commercial half already rides inside opportunity, since its default is total_vol * cpc. Pair it with gtmesh config set doctor.min_winnability 0.2 (0..1, default 0 = off), which gives the hold verdict 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 heremetaTitle / title / navTitle / metaDescription are writer-authored editorial meta (config.authored_meta); the LLM writes good titles, with rules in foundation/editorial.md. Only slug is 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 70

The 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> # → published

For what each of those steps and statuses means in full, see lifecycle & reconcile.

Guardrails

  • Write config through gtmesh configset / add / unset preserve your comments, re-validate, and roll back a bad edit. The one place hand-editing still reads better is a whole page_types entry (§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 of site.manifest.json (deterministic, no writer). That’s why the body is governed by brief_hash and the projection re-derives for free.
Last updated on