Skip to Content
DocumentationFeaturesPlatformFolding demand into a hub

Folding demand into a hub

You decree a category hub with gtmesh page add — say /food-grade-pumps, a source-less pillar with no demand behind it. Then you pull and the demand for “food grade pumps” shows up in the bag. You want that demand — the head term, its secondaries, its summed volume — to land on the hub you already decreed, not spawn a second, competing /food-grade-pumps-2 page.

That absorb is the demand fold. It’s the mechanic that makes a commercial hub work, and it’s the one place where three separate config facts have to line up exactly. This page is the recipe.

The fold only runs with entity-aware classification on (classification.entity_aware: true). With it off, seeds and demand pages are simply concatenated and any identity collision throws loudly — there is no fold. See Tuning › Entity-aware classification.

The one rule underneath it

A demand page folds into a seed iff it produces the seed’s exact identity key — the composite parent_topic × page_type × discriminator (plus market when you have more than one). Same key ⇒ same page ⇒ the demand merges into the seed (its head keyword, secondaries, and metrics), the seed keeps its slug and editorial identity. Different key ⇒ two pages.

So the whole task is: make the classifier mint the demand hub with the same identity your seed already has. A demand-minted hub is always built as:

  • parent_topic = the resolved entity’s slug,
  • discriminator = empty (the hub branch never sets one),
  • page_type = whatever your page_types rules select for a hub of that entity’s class.

Line your seed up with those three and the demand folds in. Miss any one and you get a stray page.

The recipe — three facts that must align

1. aliases so every demand variant resolves to the entity

The demand cluster is spelled a dozen ways — “food grade pumps”, “food-grade pump”, “sanitary food pump”. They only fold if they all resolve to one entity, which happens when each variant matches the entity’s slug, name, or a pipe-separated aliases value (longest match wins). Give the category entity every real spelling:

# author the entity node into the graph gtmesh graph upsert food-grade \ --label category \ --prop name="Food-Grade Pumps" \ --prop 'aliases=food grade pumps|food-grade pump|sanitary food pump'

Now every variant resolves to food-grade, so parent_topic = food-grade for all of them — they collapse into one hub group instead of scattering.

2. generic_descriptors so the keyword strips to empty (the hub branch)

Entity-aware classification strips the entity’s forms and your generic_descriptors out of the keyword, then classifies the remainder:

  • remainder empty → the entity’s hub (this is the branch you want),
  • remainder is an intent (e.g. “…installation”) → a sub-page nested under the hub.

“food grade pumps” strips the brand form food grade — leaving pumps. Unless you also strip pumps, the remainder is non-empty and the page takes the sub-page branch, minting /food-grade-pumps/pumps instead of folding into the hub. So declare the catalogue’s universal descriptors as noise:

gtmesh config set classification.entity_aware true # strip to empty → the page takes the HUB branch gtmesh config set classification.generic_descriptors '["pump","pumps"]'

Now “food grade pumps” → strip food grade → strip pumpsempty → the food-grade hub.

3. A page_types rule that gives the hub the same type as the seed

The folded demand is minted with a page_type chosen by your page_types rules. Your seed authored a page_type by hand. If they differ, the identity keys differ and the demand mints a separate page (see failure mode 1). Two ways to keep them equal:

  • Single hub type: author the seed as page_type: entity-hub and keep a when: { role: hub } rule. Every hub is entity-hub; they always match.
  • Typed hubs: if you want one schema per hub type (category-hub, manufacturer-hub), route the type on the entity’s class with when: { role: hub, entity_kind: category }, and author the seed as page_type: category-hub. See Tuning §5 for the full typed-hub recipe.
# gtmesh.config.yaml → page_types (typed-hub form) page_types: - id: category-hub role: hub when: { role: hub, entity_kind: category } # a hub whose entity is class `category` template: category-hub.md schema: category-hub.schema.yaml - id: entity-hub # generic fallback — keep LAST role: hub when: { role: hub }

And the seed page, authored to match — page add writes a source: seed node into the graph:

gtmesh page add \ --slug /food-grade-pumps \ --keyword "food grade pumps" \ --section food-grade-pumps \ --type category-hub \ --parent-topic food-grade \ --role hub --intent transactional

parent_topic = food-grade (the entity slug), page_type = category-hub (what the rule mints), discriminator empty → identity matches → the demand folds.

The two failure modes

SymptomWhat went wrongFix
A stray competing page (/food-grade-pumps and a second demand page)The demand hub’s identity ≠ the seed’s. Usually a page_type mismatch — the demand minted entity-hub while the seed is category-hub — or parent_topic isn’t the entity slug, or the remainder didn’t strip to empty (took the sub-page branch), or entity_aware is off.Align the three facts above. Most often: add the entity_kind rule (recipe #3) so the demand mints the typed hub the seed authored.
False cannibalization / a loud collisionThe identity is too coarse: an unrelated keyword cluster shares the seed’s key and gets swallowed into it — or two pages genuinely collide and plan throws duplicate composite identity.Make the identity finer (a real discriminator, or a distinct parent_topic). A throw is the engine refusing to silently merge — resolve the duplicate, don’t suppress it.

The silent typo that eats an afternoon. The when key for the entity’s class is entity_kind, not class. page_types.when accepts any keys without validation, so when: { class: category } is silently ignored — it never matches, the hub falls through to the generic entity-hub, its page_type no longer matches your typed seed, and you get failure mode 1 with no error anywhere. If a typed hub won’t fold, check this key first.

Diagnostic checklist

When a hub won’t fold, walk these in order:

  1. Is entity_aware: true? No fold without it.
  2. Does the demand resolve to the entity? gtmesh graph get <the-stray-id> and read its entity and clusters. Empty entity ⇒ your aliases don’t cover that spelling.
  3. Did the keyword strip to empty? If the stray slug has a sub-path (…/pumps), a descriptor didn’t strip — add it to generic_descriptors.
  4. Do the page types match? Compare the stray’s page_type (graph get) to the seed’s authored page_type. Different ⇒ recipe #3 (and check the entity_kind vs class typo).
  5. Same parent_topic? It must equal the entity slug on both.

Only source: keyword pages fold. A curated source: discovery term (seeds/<class>.csv) that collides with a seed does not fold — it hits the duplicate-identity check and throws. Curated hubs belong to gtmesh page add (a source: seed node), not seeds/<class>.csv.

Folding question demand into answer blocks

There’s a second, unrelated fold — for the questions pull. A question keyword (“how does a centrifugal pump work”) can either mint its own page or fold as an answer block onto the page that owns its topic. The long tail of low-volume questions shouldn’t each become a thin page; they should become answer blocks (and body coverage) on the page that already owns the topic.

That destination is _brief.questions[] — a brief field parallel to secondary_keywords. It carries the question text the writer should answer (never a metric — priority is conveyed by demand order, highest-volume first, not by numbers). It’s projected into the page’s _brief block after secondary_keywords, and it’s omitted entirely when a page has none.

The rule: threshold decides mint-vs-fold, intent decides where

# the mint-vs-fold bar (total group volume) gtmesh config set classification.question_page_min_volume 100

Choosing the value. This is not the scope floor (what enters the mesh — that’s scope.include.min_volume, typically 10); it’s the bar above which a question earns its own URL. The scaffold ships 100, which folds aggressively: question demand is long-tail (the strongest questions on a niche B2B domain are often only 40–150/mo), so at 100 most questions fold into their parent and a dedicated question page mints only for a genuinely substantial one. That matches the topical-authority stance — a comprehensive page answering twenty questions beats twenty thin pages. Lower it (e.g. 50) if you want strong standalone questions to earn their own pages more readily; the fold zone is then 10–49 instead of 10–99.

One knob (opt-in — unset ⇒ every question mints its own page, exactly as before):

  • A questions-pull group at or above question_page_min_volume warrants — and mints — its own page (the pre-existing behavior).
  • Below the bar it does not mint. Its question text folds onto the questions[] of a page that owns the topic — and the fold runs over the whole desired mesh (keyword + discovery + seed + your existing built pages), so a question enriches a page it didn’t mint alongside. A page owns the topic when it either shares the question’s parent_topic or its parent_topic/primary_keyword is a word-boundary substring of the question (so “what is pump cavitation” folds onto /glossary/cavitation — non-entity topics never share a parent_topic, so this containment is how content pages connect).
  • Among the owners, intent picks which one: a topic can be owned by several pages (a content page and a commercial page), so the fold prefers the candidate whose intent matches the question’s. An informational question (“how does X work”) lands on the content page; a commercial one (“best X for Y”, “does X support Y”) lands on the commercial page. Among intent-matches a content page beats the topic’s hub — a category/entity hub is where the topic’s commercial demand converges, so its head-term volume would otherwise always win the tiebreak and pull the question onto the commercial hub instead of the guide beneath it. The hub is used only when it’s the sole intent-match. With no intent match at all it folds onto the topic’s primary page; only when no page owns the topic does the question fall back to minting (it is that topic’s page).
  • A sub-threshold question that resolves to the same identity as an existing page folds onto it — it never mints a rival, so plan never fails with a duplicate composite identity error.
  • A question group whose identity already has a built page never folds, whatever the threshold says. A page that was written, sealed and published is work, and demand slipping below the bar is a reason to reconsider it, not to remove it from the desired set. Retiring it stays deliberate: page retire or demote --to backlog. (Without this, raising question_page_min_volume — or renaming a bag file, since pull is parsed from the filename — could drop a live page out of the plan.)
  • When a fold does remove a committed (but unbuilt) page, plan says so rather than letting it disappear silently.

The containment limit. Word-boundary containment catches a question that restates a page’s topic string. It won’t connect a question to a page whose identity is a full phrase that shares no substring — e.g. “what is a pump curve” won’t fold onto /guides/how-to-read-a-pump-curve, because neither string contains the other. Give that page the subject as an alias/secondary keyword the question restates, or the question mints its own page (surfaced by question-content-gap when nothing owns the topic). The engine is deterministic + LLM-free, so it matches on shared text, not meaning.

That’s why there is no faq_target flag — routing is by intent, so both content and commercial pages get demand-validated FAQs, each from the right demand, and no page-type name is hardcoded in the engine (invariant 4).

sections_map coverage IS the routing — enumerate your question phrasings. Intent for a question resolves through the same chain a page uses — section_intent pin → taxonomy.intents order → default_intent ?? intents[0]. But most questions come back from Ahrefs with a blank intent (on a niche B2B mesh, observed fill rate ~14% on the questions pull vs ~85% on exact), so in practice the section decides the intent — and a phrasing that matches no sections_map rule gets no section, no section_intent pin, and defaults to taxonomy.intents[0], which can drop an informational question onto a commercial page. So route your question phrasings to intent-appropriate sections and cover the real variety — not just “what is” / “how to” but “what does … do”, “why does”, “can you”, “is it”, “how much”, etc. — then pin those sections in section_intent. Tune it exactly as you tune page intent.

When a topic has no intent-appropriate page. If a sub-threshold informational question’s topic is owned only by a commercial page (no glossary/guide/other content page, and no above-threshold informational question page), the fold has no content page to route to and falls back to the commercial hub. That’s a content gap — real question demand with nowhere to answer it — and gtmesh doctor surfaces it as question-content-gap (a hub carrying folded questions whose topic has no content page). Author a glossary/guide for the topic (gtmesh page add), and the next plan routes the question there. The check stays silent once a content page exists, so it’s a clean signal, not routing noise.

Questions are identified deterministically by pull provenance (the export came from the questions pull), not by an interrogative regex — so a mixed group (a question sitting alongside a matching-terms sibling) mints normally and nothing is lost. Folding consumes only admitted demand — a staged question you haven’t stage admit-ed never folds.

How the writer uses them

The questions are the writer’s demand map for the whole article, not just an FAQ appendix. The engine-owned article-writer skill answers the substantive, higher-demand ones in the body prose (a “how does X work” is a section led by its answer) and surfaces the longer tail in the schema’s faq[] — so the page actually satisfies the demand it was assigned. The review-gate already required “real People-Also-Ask phrasings, not invented ones”; _brief.questions is finally the data that satisfies that bar.

Generating them — the operator loop

Questions come from the questions pull, which is per cluster (your seeds), not per page — same shape as the matching-terms pull:

  1. Pull. gtmesh pull demand --pull questions --stage <cluster> for each cluster. Pair it with the per-pull floor — questions are long-tail, so adapters.ahrefs.pull_overrides.questions.volume_floor must be low (the scaffold ships 10) or the pull returns nothing. (A normal pull demand already runs both pulls if pulls lists questions.)
  2. Curate. gtmesh stage listgtmesh stage admit --keyword …. Raw question demand carries a lot of out-of-ICP noise (adjacent markets, consumer variants); admit only the ones your buyer would ask. Folding never bypasses this.
  3. Plan. gtmesh plan — questions ≥ the bar mint pages; the rest fold onto their intent-matched parents.

See the Ahrefs integration spec for the per-pull floor.

Upgrading an existing mesh

Upgrading is not one command, and gtmesh upgrade populates no questions. It’s an opt-in feature that needs real question demand to do anything — and you almost certainly don’t have any yet, because before the per-pull floor landed, the questions pull returned ~nothing at the shared volume_floor. So here is exactly what happens at each step.

  1. gtmesh upgrade — ships the engine-owned changes: the fold logic, _brief.questions[], and the updated article-writer + review-gate skills. Nothing else happens. No config is touched, no questions are pulled, no page is rewritten. The feature is dormant. (A safe no-op: every existing page still re-plans to noop — the brief hashes its keyword arrays as a set and omits questions when empty, so a question-less page’s brief_hash is byte-identical to before.)
  2. Config it (project-owned — upgrade won’t). Add classification.question_page_min_volume (the scaffold ships 100), the per-pull floor adapters.ahrefs.pull_overrides.questions.volume_floor: 10, and make sure your sections_map routes question phrasings to intent-appropriate sections with section_intent pins (that’s what makes intent-routing work — see the callout above).
  3. Re-pull — you must pull again. Questions are not auto-populated. Run gtmesh pull demand --pull questions --stage <cluster> for each cluster to get a fresh questions bag (your old one is empty). Per cluster, not per page.
  4. gtmesh stage admit the ICP-relevant questions (raw question demand is noisy).
  5. gtmesh plan — now the fold runs. It shows exactly which pages change: questions ≥ the bar mint new pages; sub-threshold ones fold onto their intent-matched parents, moving those parents’ brief_hash.
  6. gtmesh apply then the article-writer on the pages the plan flagged.

So yes — to benefit, the pages that receive questions get regenerated. That’s the point: a page can’t answer a question it didn’t have. But the scope is bounded and visible in plan before you apply — only pages that actually received new question demand rewrite. Question-less pages, and pages whose question set didn’t change, are untouched (and a pure demand reshuffle never rewrites — the hash is set-based). It is not a blanket regeneration of the mesh.

If the fold target is still planned, nothing appears in the page YAML yet. _brief.questions is written into a bundle only when the page is scaffolded — i.e. once it reaches a buildable status. A question that folds onto a planned (un-built) page moves that page’s brief_hash, but you won’t see _brief.questions in content/…/index.yaml until you gtmesh page promote it (→ apply scaffolds the bundle). So on a fresh mesh whose targets are all planned, plan shows the fold but the YAML looks unchanged — that’s expected, not the feature no-op’ing. Promote the target (or it’s already built) and the questions land.

Last updated on