Reference
Every gtmesh command, grouped by what you’re doing. Each entry gives the one-line
description, any positional arguments, and the flags — taken directly from the program.
For the definitive, version-exact list, run gtmesh --help or gtmesh <command> --help.
Not installed yet? See Installation.
New here? The walkthrough tells the end-to-end story, and lifecycle / tuning cover the day-to-day loops. New terms (graph store, identity, bundle, mesh) are in the glossary.
Global options
These apply to every command (set them before the verb, e.g. gtmesh --project ./my-mesh status):
| Option | Description |
|---|---|
--project <dir> | Project directory (the scaffolded mesh repo). Default: . |
--json | Machine-readable output |
--quiet | Suppress non-essential output |
--no-color | Never colorize output |
Three commands colorize — plan, apply and doctor — and each
honours --no-color. Color is on only when the destination is an interactive terminal, so a pipe, a
redirect (gtmesh plan > plan.md), --json and CI all get plain text without asking. --no-color
turns it off even on a terminal; the NO_COLOR and FORCE_COLOR environment variables are honoured
too.
There is also --version (prints the gtmesh version) and --help.
A command that fails prints its reason as a single error: … line and exits non-zero — the message is
the whole answer (graph-write contention, for instance, tells you which process holds the lock). Set
GTMESH_DEBUG=1 to get the full stack trace instead, when the message isn’t enough to diagnose it.
Self-describing help
The CLI is self-describing, so an operator (human or AI) can discover it without leaving the shell:
gtmesh --helplists every verb and ends with aDocs:link to this reference.gtmesh <command> --helpprints a verb’s synopsis and flags, ending with aDocs:link to that command’s reference section.- Adding the global
--jsonrenders help as a single machine-readable document —gtmesh <command> --help --jsonreturns{ name, description, usage, docs, options, arguments, subcommands }, wheredocsis the command’s reference URL. Version-matched and offline; handy for tooling.
Scaffold
These get a project off the ground. See the walkthrough.
init
Scaffold a clean, generic mesh repo (placeholder config, empty reference CSVs, generic
foundations/templates/schemas). Add a worked-example demo with --template <name>.
| Option | Description |
|---|---|
--force | Overwrite existing files instead of skipping them |
--template <name> | Overlay a bundled demo/starter template on the generic base (e.g. acme) |
--list-templates | List the bundled templates and exit |
A plain gtmesh init produces a demo-free repo you tune to your domain. gtmesh init --template acme
overlays the acme-integrations worked example (a fictional SaaS integrations mesh) on top of the
generic base — config + seed reference data + brand prose (with the strategic “why” folded in) + the template-specific
connector page type — so you can explore a fully tuned mesh. Run gtmesh init --list-templates to
see what’s available.
After init, open the repo in Claude Code and run the operator skill: it drives the build
loop conversationally (context → demand-first research → config/reference via these verbs → plan →
apply → doctor), one slice at a time, with a human approval gate on every judgement call. There is
no separate pre-init discovery step — you init first and fill the missing pieces in the loop.
harvest
Print how to run the harvest skill (entity-class term harvesting) for a configured class.
| Argument | Description |
|---|---|
[class] | The discovery class to harvest (omit to list configured classes) |
This is a bridge to a skill — it validates the class and prints the prompt; the harvesting itself
happens in Claude Code and writes a curated seeds/<class>.csv.
Configure — edit the config incrementally
config
Read and edit gtmesh.config.yaml a value at a time, instead of hand-writing YAML. Every
write is comment-preserving and write-then-validate: the edit is applied to the YAML,
re-validated against the config schema, and on failure rolled back — the file is left
untouched and the error is printed. --dry-run prints the resulting diff without writing.
Paths are dotted, with [N] for array indices: taxonomy.markets[0],
sections_map[2].when.match.
The file holds decisions; the schema holds defaults. A key you leave out resolves from the config schema at load, so an absent key is not an unset one — it is one the engine still owns. That is what makes a default shippable: change it in an engine release and every mesh that never overrode it picks the new value up, while a mesh that did set it keeps its choice.
config get <path>
Print the value in effect at a path. A path the file sets prints that value; a path the file leaves out prints the schema default and says where it came from.
config list
Print the config file as JSON — the decisions this project has written.
| Option | Description |
|---|---|
--resolved | Print the EFFECTIVE tree the engine loads, with every schema default filled in |
config describe [path]
Print what a config path is — its type, whether it is required, its default, its allowed values, what it controls, and the docs page for its block. Derived from the same schema the engine validates against, so it cannot drift from what is actually settable.
With no argument it prints the top-level index (every block, with how many paths sit under it). Name
a path and it prints that key plus everything under it. page_types[] is the shape of each item of
an array (a concrete index such as page_types[0].template resolves to the same entry);
environments.* is the shape under any key you name. An unknown path fails with the nearest ancestor
the schema does know and the paths under it.
It reads the schema rather than your project, so it works anywhere — including before gtmesh init.
gtmesh config describe # the map: every top-level key
gtmesh config describe taxonomy # one block, opened up
gtmesh config describe links.up --json # the machine-readable form--json emits { schema_version, path, docs, fields[] }, each field carrying path, type,
required, default, enum, constraints, description, docs, and leaf — the surface an
agent traverses, and the reason there is no separate JSON Schema file to maintain.
config clean
Remove every value that already equals its schema default, leaving the file holding only this
project’s decisions. Behavior-neutral by construction: a key is droppable exactly when
removing it leaves config_hash byte-identical, and the write is refused outright if the hash
moves. A value you overrode is never touched. Comments on surviving keys are preserved (a comment
attached to a dropped key goes with it).
| Option | Description |
|---|---|
--dry-run | Print the paths it would drop and the resulting YAML diff, without writing |
gtmesh config clean --dry-run # see what it would reclaim
gtmesh config clean # apply it; config_hash is unchangedconfig_version is left in place — it stamps the file format for
gtmesh upgrade, not a setting to reclaim.
aeo:, images:, rubrics: and discovery: are kept too, and the run says so. config_hash
proves a removal is inert for the engine, which resolves an absent key from the schema; the
engine-owned skills (review-gate, image-director, harvest) read the file, so a value that
merely restates a default is still their input.
config set <path> <value>
Set a scalar (or a JSON literal, for object/array values) at a path.
| Option | Description |
|---|---|
--dry-run | Print the resulting YAML diff without writing |
gtmesh config set kind commercial_catalog
gtmesh config set link_rules '[{"for":{"role":"hub"}}]'config add <path> <value>
Append a value to an ordered array (e.g. a sections_map rule). Ordering matters —
sections_map is first-match-wins — so placement flags control where the element lands.
An array the file leaves out is still running its schema default, so add writes that default
out first and appends to it: gtmesh config add taxonomy.markets gb on a file with no markets
key yields [ us, gb ], not [ gb ].
| Option | Description |
|---|---|
--at <n> | Insert at index N (0 = front) |
--before <match> | Insert before the first element whose JSON contains this substring |
--after <match> | Insert after the first element whose JSON contains this substring |
--dry-run | Print the resulting YAML diff without writing |
# put a glossary rule ahead of the catch-all "best" rule
gtmesh config add sections_map '{"when":{"match":"what is"},"then":{"section":"glossary"}}' --before '"best"'config unset <path>
Remove the value at a path — that is, remove a decision. A path the file leaves out has no
decision to remove (it already resolves from the schema default), so unset reports the value in
effect and points at the config set that would pin a different one.
| Option | Description |
|---|---|
--dry-run | Print the resulting YAML diff without writing |
Derived fields are rejected. Some config is computed by the engine — notably market’s
membership in identity_keys, which the engine auto-joins whenever taxonomy.markets has
more than one market. config refuses to hand-edit those and points at the real lever
(gtmesh config add taxonomy.markets <cc>), so your edit can’t fight the transform.
Sugar helpers, if ever added, stay namespaced under config (gtmesh config add-rule …) —
never a top-level verb.
Data — pull keyword data into the bag
pull
One verb for “get data from an external source”, split by channel — what the data is. Every
channel appends an immutable, timestamped export under data/raw/ and takes two shared modifiers:
--dry-run (report the planned request, spend nothing) and --stage (write to the git-ignored
staging area instead of the committed bag). --source <provider> constrains a channel to one
provider.
| Channel | What it writes |
|---|---|
pull demand | The keyword bag (data/raw/keywords/) — the one channel plan reads |
pull ai | AI-answer citation counts per provider (data/raw/ai/) |
pull performance | Search-performance snapshots + the index sweep (data/raw/performance/) |
pull links | Backlinks, domain rating, referring domains (data/raw/links/) |
pull serp <keyword> | One keyword’s SERP (data/raw/serp/) |
pull top-pages <domain> | A competitor’s top organic pages (data/raw/top-pages/) |
pull demand
Pull keyword demand into data/raw/keywords/. Four ways in, all writing the same bag:
gtmesh pull demand # every root topic in the graph
gtmesh pull demand --topic slack # one root topic
gtmesh pull demand --keyword "pump,water pump" --bag pumps # explicit terms, expanded
gtmesh pull demand --keyword "pc pump" --bag glossary --pull overview # explicit terms, EXACT Vol/KD
gtmesh pull demand --source csv --input export.csv # import a CSV, no API budget
gtmesh pull demand --source seeds --discovery-class glossary # refresh a curated term list's metrics| Option | Description |
|---|---|
--source <source> | Demand adapter (ahrefs | csv | seeds). Default: ahrefs |
--topic <value> | Scope the pull to ONE root topic — the value (dosing) or its node id (topics/dosing). Default: every root topic in the graph |
--keyword <kw> | Comma-separated explicit terms to pull instead of the graph’s root seeds |
--bag <name> | The bag path an explicit-term pull files under — data/raw/keywords/<source>/<ts>--<name>--<pull>.csv (required with --keyword) |
--pull <kind> | Single pull (matching-terms | questions | overview); overview = exact Vol/KD for --keyword, no expansion |
--input <file> | Source CSV (for --source csv) |
--discovery-class <id> | Discovery class to refresh (for --source seeds; default: all configured) |
--country <cc> | ISO alpha-2 country override (default: the configured market) |
--dry-run | Report the planned pulls without spending API credits |
--stage | Write to the git-ignored staging area (.gtmesh/staging), not the committed bag — plan ignores it until stage admit |
--source ahrefs (and pull ai, which reuses the same token) needs AHREFS_API_TOKEN
set. --source csv imports a CSV with no API budget. --source seeds refreshes the metrics
for a curated discovery term list (terms stay in seeds/<class>.csv; metrics live in
data/raw/).
With no --topic and no --keyword, pull demand pulls every root topic in the committed graph
(see topic) — each root topic’s seed_terms are the pull heads and its exclusions layer
onto the global adapters.ahrefs.exclude_substrings for that pull. Root topics are the only place a
demand pull reads its terms from, so growing the pull frontier is gtmesh topic add <value> --root --seed "<term>".
Explicit terms (--keyword) take their seeds straight from the flag, so a demand pull works
during discovery cycle 0 — before gtmesh init exists — and during harvest, when candidates aren’t
committed to seeds/<class>.csv yet. --pull overview swaps expansion for exact per-term Vol/KD,
which is what harvest ranks brainstormed candidates on.
Because a pull writes the committed bag by default, the next gtmesh apply will catalogue every
term as a page — so a plain pull demand --pull overview is a validation pull, not an exploration
sandbox. To rank and cut candidates without committing them, add --stage: the pull lands in
the git-ignored staging area, plan ignores it, and gtmesh stage admit promotes only the keepers.
The command warns when you pull non-staged for this reason.
pull performance --source gsc — the performance snapshot
The direct form of the pull performance
channel’s gsc pull — the channel runs exactly this for the gsc provider, so both spellings write
the same snapshot. Pulls Google Search Console performance for the whole configured property —
three pulls, committed to data/raw/performance/gsc/:
- pages — per-URL clicks / impressions / CTR / average position
- queries — per-query rows joined to their ranking URL
- daily — site-level totals per day across both windows (the trend line behind
gtmesh ui’s Dashboard tiles)
Each pull’s CSV carries two report windows via a window column: current (the trailing
adapters.gsc.window_days, default 28, ending 3 days back — GSC’s data lag) and previous (the
block immediately before it), so one snapshot is self-sufficient for trend deltas. --topic,
--pull, --input, and --discovery-class belong to the demand sources — gsc always snapshots the whole
property; scope to a topic at read time with topic performance.
After the analytics pulls, the same run sweeps indexing status (skip it with --no-index):
- index — every published page’s live URL through the URL Inspection API: verdict, coverage state (e.g. “Crawled - currently not indexed”), robots/fetch state, last crawl, and the Google-selected vs declared canonical. The sweep is paced sequentially under the API’s per-minute limit; the daily quota is 2,000 inspections per property, so a mesh-sized sweep fits a daily run. URL inspection requires the service account to be a Full user on the property (a Restricted user can read analytics but not inspect).
- sitemaps — the property’s submitted sitemaps with last-submitted time, pending flag, and error/warning counts.
The sweep builds absolute URLs from site.base_url + each page’s slug — with site.base_url
unset, the run warns with the fix (gtmesh config set site.base_url https://example.com) and skips
the sweep + sitemaps; the analytics pulls still succeed. All files from one run share the same
timestamp prefix, so the five CSVs read as a single snapshot.
One-time setup (the command checks each prerequisite and errors with the exact fix):
- Create a service account in Google Cloud (IAM → Service Accounts) and download its JSON key.
- In Search Console → Settings → Users and permissions, invite the service account’s
client_emailas a Full user (a standard invite — revocable anytime, no Google credentials shared). - Save the key as
gsc-key.jsonat the mesh root — already in the scaffold’s.gitignore, so it never commits, and the adapter finds it with no configuration. (A key stored elsewhere:GSC_KEY_FILE=/path/to/key.jsonin.env.localoverrides.) gtmesh config set adapters.gsc.property <property>— a URL-prefix property (https://example.com/) or a domain property (sc-domain:example.com).
The snapshot is a read-time observation, not graph state: flatten never folds it and it never
enters plan/apply — only topic performance (and later the UI/doctor)
joins it against folded demand when you ask.
pull performance — the search-performance channel
Refreshes every configured search-performance provider in one verb — the cadence form of the
performance snapshot. performance is a channel, not a single adapter: each configured
provider writes its own snapshot to data/raw/performance/<provider>/ on the same pinned columns
(--source <provider> constrains the fan-out to one). A provider is configured by config alone:
- gsc — configured when
adapters.gsc.propertyis set. The pull is exactlypull performance --source gsc, index sweep included (--no-indexapplies to this provider only — Bing has no sweep). - bing — configured when the
adapters.bingblock is present. Pulls Bing Webmaster Tools search performance for the site (GetPageStats+GetQueryStats+GetRankAndTrafficStats→ the samepages/queries/dailysnapshot files). Bing’s query stats are site-level, so its queries rows carry no ranking URL; positions come from Bing’sAvgImpressionPosition, and both report windows derive from the date-bucketed rows on the same clock conventions as gsc (current= the trailingadapters.bing.window_days, default 28, ending 3 days back;previous= the block before), so a Bing snapshot is trend-self-sufficient too.
Providers run sequentially (gsc first), one snapshot clock per provider. A provider failing
mid-fan-out reports its error and the run continues — the exit is 1 only when every provider
failed, so a partial snapshot still lands (a degraded success, warned per failure). --dry-run
prints each provider’s request plan without touching the network, and --stage works as for the
other sources.
Bing setup (one-time; the command checks each prerequisite and errors with the exact fix):
- Verify the site in Bing Webmaster Tools — “Import from Google Search Console” is the fastest path when GSC is already set up.
- Settings → API access → API key — generate (or copy) your key.
- Export
BING_WEBMASTER_API_KEY=<key>(e.g. in.env.local— it must never be committed; the key rides the environment, never config). - Opt the provider in:
gtmesh config set adapters.bing '{}'— the block’s presence is the opt-in. The site URL derives fromsite.base_url.
Downstream reads stay per-provider — a Bing #3 is never blended with a Google #3. The report
defaults to the gsc frame and scopes with
topic performance --source bing; site-total event counts (clicks,
impressions) are the only thing aggregated across providers.
pull ai — the AI-visibility snapshot
Pulls AI visibility — how often AI answers (ChatGPT, Google AI Overviews, Gemini, Perplexity,
Copilot, …) cite your domain. ai is a channel, not a single adapter: the verb fans out to
every configured AI provider, each writing its own snapshot to data/raw/ai/<provider>/
(--source <provider> constrains the fan-out to one). Today’s provider is ahrefs — Site
Explorer’s ai-responses-count, included in every Ahrefs plan on the same AHREFS_API_TOKEN: one
request covers every configured platform and writes per-platform site-level counts of citing AI
answers and distinct cited pages (platform,citations,pages).
The platforms default to chatgpt, google_ai_overviews, gemini, perplexity, copilot; override
the set with adapters.ahrefs.ai.platforms (also available: google_ai_mode, grok). Cost is
flat (~90 API units per pull) — independent of mesh size and platform count.
The domain derives from site.base_url — unset, the run errors with the fix
(gtmesh config set site.base_url https://example.com). --topic, --pull, --input, and
--discovery-class belong to the demand sources — the pull is domain-level. Every provider’s CSV from one
run shares a timestamp prefix (one snapshot), and --dry-run / --stage work as for the other
sources.
Like the GSC snapshot, AI visibility is a read-time observation channel — it never enters
plan/apply; the report and gtmesh ui read each
provider’s latest snapshot when you ask. Providers are never merged (each samples AI answers its
own way) — the report keys them separately, and more providers join as they land (Bing’s Copilot
citations join the day Microsoft ships an API for its AI Performance report — today that report is
dashboard-only).
pull links — the authority snapshot
Pulls backlinks — the external authority arriving into the mesh’s internal weight-flow
structure, the third observation channel beside performance and ai. links is a channel:
the verb fans out to every configured links provider, each writing its own snapshot to
data/raw/links/<provider>/ (--source <provider> constrains the fan-out to one):
- ahrefs — the primary frame, on the same
AHREFS_API_TOKEN(no config block; the token’s presence is the gate). Five pulls per snapshot:- pages — every page of the domain with its live backlink count, referring-domain count,
and URL rating (
url,backlinks,refdomains,url_rating), one request viasite-explorer/pages-by-backlinks. Joins straight through the URL→page→topic rollup. - site — live totals + the current Domain Rating in one row
(
backlinks,refdomains,domain_rating). - history — the monthly DR trend line (
date,domain_rating), pulled from a stabledate_from(default2020-01-01; override withadapters.ahrefs.links.history_from) so one pull backfills the whole line and the request is identical run-to-run. - domains — the who-links-to-us list: the top 500 referring domains by rating, live
links only (
domain,domain_rating,links,first_seen,is_spam). - backlinks — the attribution edges: which pages each referring domain links to
(
domain,url,links,dofollow,is_spam,first_seen), live links viasite-explorer/all-backlinksaggregated per referring domain × target URL.domainis the actual referring host, subdomain included (blog.example.com stays distinct from example.com). At read time each edge’s target URL joins onto its mesh page through the same resolver as the pages pull — a target that maps to no mesh page keeps its edge with no page attached: that’s the redirect-reclaim signal (a legacy URL still holding live links). Capped at 2,000 rows ordered by the linking site’s rating, so the strongest domains survive a truncation (the run warns when the cap hits).
- pages — every page of the domain with its live backlink count, referring-domain count,
and URL rating (
- bing — configured when the
adapters.bingblock is present (the same opt-in andBING_WEBMASTER_API_KEYas the performance provider). One pull:GetLinkCounts, Bing’s per-page inbound-link counts from its own index — counts only, so its snapshot’srefdomains/url_ratingcells stay honestly empty (empty reads as unknown, never 0). A free cross-check beside the ahrefs frame.
Cost (ahrefs): refdomains on the pages pull is surcharged at 5 API units per row —
roughly 5 × your published page count per snapshot — and the backlinks pull runs about 6
units per backlink row (measured live), capped at 2,000 rows so a big profile can’t spend
more than ~12,000 units; every other selected field is surcharge-free, and the fields that
would cost 10 units/row (traffic estimates) are simply not pulled. --dry-run prints the
accounting before any unit is spent.
The domain derives from site.base_url — unset, the run errors with the fix. --topic,
--pull, --input, and --discovery-class belong to the demand sources — the pull is site-wide; scope to
a topic at read time. Providers run sequentially (ahrefs first); a provider failing reports its
error and the run continues — exit 1 only when every provider failed. --stage works as for the
other sources, and each provider’s files share one timestamp prefix (one snapshot).
Link indexes differ, so providers are never merged — each snapshot lives in its own bag dir
and downstream reads stay per-provider (ahrefs is the primary frame when present). Google
contributes nothing here: Search Console’s Links report is dashboard-only (no API), so the
channel is ahrefs-primary with Bing as the cross-check. Like every snapshot, links are a
read-time observation channel — they never enter plan/apply.
pull serp <keyword>
Pull a keyword’s SERP from Ahrefs (serp-overview); append a timestamped export to
data/raw/serp/. The operator skill reads it to decide a cluster’s comparison model from evidence.
| Argument / Option | Description |
|---|---|
<keyword> | The keyword to pull the SERP for (a discovery cluster head) |
--country <cc> | ISO alpha-2 country (default: taxonomy.markets[0]); required in a pre-init directory |
--limit <n> | Limit to the top N organic positions |
--dry-run | Report the planned pull without spending API credits |
--stage | Write to the git-ignored staging area, not the committed bag |
pull top-pages <domain>
Pull a competitor domain’s top organic pages from Ahrefs; append a timestamped export to
data/raw/top-pages/. Feeds the operator skill’s competitor analysis.
| Argument / Option | Description |
|---|---|
<domain> | The competitor domain to pull top pages for (e.g. example.com) |
--country <cc> | ISO alpha-2 country (default: taxonomy.markets[0]); required in a pre-init directory |
--date <yyyy-mm-dd> | Metrics reporting date (default: today, UTC) |
--mode <scope> | Search scope: exact | prefix | domain | subdomains (default: subdomains) |
--limit <n> | Max pages to pull |
--dry-run | Report the planned pull without spending API credits |
--stage | Write to the git-ignored staging area, not the committed bag |
topic
A root topic is the pull entry-point: a graph node that owns its seed_terms (the Ahrefs pull heads)
and per-topic exclusions. It’s how you say “this domain is worth pulling demand for” — authored directly
in the graph, so the operator never edits two configs. topic add --root authors one; pull reads them.
topic verbs take the bare topic value (chemical), not the full node id — they slugify it and prepend
topics/. As a convenience they also accept the full id (topics/chemical) and strip the prefix, so a
value copied from a graph command (which does take full ids, e.g. graph get topics/chemical) resolves
to the same node instead of double-prefixing. page add --parent-topic normalizes the same way.
topic list
List topics with their demand, highest-opportunity first, so you can prioritize which to (re-)pull by
the value at stake — not raw volume. Each line shows opp <opportunity> · <total_vol> vol "<head>", so a
lower-volume but higher-CPC topic can outrank a high-volume low-value one. A mesh with no CPC data degrades to
demand (volume) ordering. Read-only.
Every owned topic node carries its head’s full demand shape (T2): total_vol, difficulty (KD),
cpc, and the derived opportunity (value at stake) + effort (cost to win) — inspect it all with
gtmesh graph get topics/<value>. The opportunity/effort formulas are config-tunable (see
scoring).
| Option | Description |
|---|---|
--roots-only | List only root topics (the pull targets) |
--thin [max] | The coverage view instead: only topics with at most max spoke members (default 1), thinnest first, with member + apex-owner counts. A topic’s own apex hub/pillar is not counted as a member |
gtmesh topic list --thin 2 # topics with 0-2 members — where the mesh is a hub with nothing under ittopic members <value>
The pages MEMBER_OF a topic — its cluster — slug-sorted with each page’s role and status. Accepts
the topic value (dosing), its node id (topics/dosing), or its name. The apex owner is not a member of
the topic it owns, so it is excluded. Read-only; needs committed state.
gtmesh topic members lobe-pumpstopic refresh [<value>]
The do-a-refresh front — one command for the whole cadence, so you never have to remember
pull → plan → apply. It orchestrates the existing verbs: pull (pull the topic’s, or every
root topic’s, demand from Ahrefs) → plan (preview the diff) → stop at the apply gate. Review the
plan, then gtmesh apply (or pass --apply to bundle the write + a topic status). It adds no engine
logic — each step keeps its contract (plan read-only, apply the writer) — it just runs them in the
right order. This is what the staleness flags (topic status’s refresh action, topic fill’s
stale-demand nudge, a freshly-rooted topic) point you at.
| Argument / Option | Description |
|---|---|
[value] | A single root topic to refresh (omit to refresh every root) |
--apply | Also apply the plan + show topic status (opt-in the graph write) |
--dry-run | Plan against the existing bag — no pull, no API credits |
pull spends Ahrefs credits (it’s a real pull); --dry-run re-plans against data you already have.
topic status [<value>]
The refresh loop’s oracle: per topic, demand vs coverage → the concrete next action, so after a
gtmesh topic refresh you know exactly what to do. Reads each topic’s demand shape and its committed
coverage (apex HUB_OF/PILLAR_OF + MEMBER_OF edges). Opportunity-ordered; give a <value> to deep-dive
one topic (its apex + members). Read-only.
The next action is one of: refresh (a root with no demand pulled — gtmesh topic refresh <value>) ·
author (demand, no page) · build the cluster (a hub with no members) · ✓ covered ·
pull-to-assess (pages exist, but the topic’s demand was never pulled — root it and refresh it, then it
can be judged like everything else) · and should-root: a demand-bearing built hub that
isn’t a root (so pull never refreshes it), each with a ready gtmesh topic add <value> --root --seed "<head>".
should-root is most dramatic on an older page-first mesh (many hubs won’t be roots), but it’s the same drift
signal on any mesh — see topic audit for the grouped whole-mesh version.
| Argument | Description |
|---|---|
[value] | A single topic to deep-dive (omit for the whole-mesh dashboard) |
topic audit
A holistic, read-only foundation report — run it on any mesh, any time to see where every topic stands against the topic-first model. Groups the findings, opportunity-ranked, each with its fix command + a summary:
- PROMOTE TO ROOTS — demand-bearing hubs that aren’t roots (won’t refresh) →
gtmesh topic add --root - PULL TO ASSESS — built topics whose demand was never pulled →
gtmesh topic add --root+topic refresh - BUILD OR DROP — roots with no page →
gtmesh topic add --apex(ortopic unroot) - PULL — roots with no demand pulled yet →
gtmesh pull demand
Run it whenever — it’s the same check on a fresh build (are my roots built?), a mature mesh (has anything drifted — a new hub that should be rooted, a root that lost its page?), or an older page-first mesh (its most dramatic first use, where many hubs won’t be roots yet). Read-only, so it’s cheap to run on a schedule.
PULL TO ASSESS is the group for what you can’t rank yet. A topic whose demand was never pulled reads
total_vol: 0, so no demand test can say anything about it — including “it isn’t worth rooting”. If it has
structural presence (an apex and/or member pages), that structure is real evidence the demand question is
worth asking: root it, refresh it, and it joins the demand-ranked groups on the next pass. Findings are ranked
by member count — the structural evidence, not an opportunity score — and every one is listed.
Set doctor.min_root_opportunity to focus the report — a hub is only flagged promote when its worth
(its opportunity, or total_vol when there’s no CPC data) is at/above the threshold. 0 (default) flags any
demand; raise it on a large mesh so only the valuable hubs surface. The same threshold gates the
should-root line in topic status. It does not apply to PULL TO ASSESS — a
threshold over demand can say nothing about a topic whose demand is unmeasured, so those findings always show.
# only flag demand-bearing hubs worth ≥ 30000 as should-root
gtmesh config set doctor.min_root_opportunity 30000topic candidates
The selection gate: which topics are worth the effort? Every other ranking surface (topic list,
topic status, topic audit) orders by opportunity alone, so a huge topic behind a keyword-difficulty
wall outranks a smaller one you could actually own. topic candidates adds the missing axis —
winnability — and turns the ranking into a recommendation. Read-only.
score = opportunity × winnability
winnability = max(head, tail) # 0..1; no KD data on either side ⇒ 1
head = (100 − head keyword's own KD) / 100 # can we take the head directly?
tail = (100 − median KD of the topic's UNCOVERED demand) / 100 # is there a way IN?Winnability is read at the topic level, not the keyword level — a topic is won by its cluster, so the
head’s difficulty is a horizon, not a veto. A hard head above a soft uncovered tail is a good investment
you climb toward: cover the tail, authority pools at the apex, the head comes into reach. A hard head with
no tail is the one genuine skip. See
Winning a topic.
Each row reports which side carried it (via head / via tail) and, when there’s a ladder, the easiest rungs:
widget score 18000 INVEST
opp 20000 · head KD 85 · tail KD 10 (3/3 scored, 650 vol uncovered) · win 0.90 via tail · apex·4m
rooted with 4 member(s) — grow the cluster; head is hard (KD 85) but 3 uncovered
term(s) at median KD 10 are the way in
→ gtmesh topic fill widget
way in: "how to fit a widget" (KD 5, 300 vol) · "widget troubleshooting" (KD 10, 200 vol)The commercial half is already inside opportunity — T2’s default total_vol × cpc, where CPC is the
market’s buyer signal (advertisers bid where money is). So the score is the commercial ∩ winnable
intersection; it isn’t multiplied by CPC a second time. Scoring against your declared offering rather
than market CPC is a later phase.
Reading KD —. Difficulty is optional provider data — much of the low-volume long tail is never scored,
and gtmesh records that as unknown, never 0 (unscored and uncontested are opposite claims). A row with
no difficulty evidence on either side shows win 1.00 (no KD data), and the ranking is then effectively
opportunity-only — the honest answer when nothing is known. A tail KD — alongside 0 vol uncovered means
something quite different and good: every keyword under that topic already has a page.
Each candidate gets a verdict with the command that acts on it:
| Verdict | When | Fix |
|---|---|---|
| build | The topic has no apex — no home for authority to pool at | gtmesh topic add <v> --head … --apex … |
| promote | Built, but not a root — its demand never refreshes | gtmesh topic add <v> --root --seed "<head>" |
| invest | Rooted with an apex — grow the cluster | gtmesh topic fill <v> |
| hold | Winnability under doctor.min_winnability — no way in: the head is a wall and so is the uncovered tail | (none — revisit when the head softens or new low-KD demand appears) |
Topics with no demand at all can’t be scored; they’re excluded and counted in the footer, since
topic audit already owns that case. A mesh with no CPC data degrades gracefully — the score
falls back to total_vol × winnability rather than collapsing to zero.
| Option | Description |
|---|---|
--limit <n> | How many candidates to show (default 15) |
--min-score <n> | Drop candidates scoring below this |
--verdict <v> | Only one verdict: build | promote | invest | hold |
Two knobs, both optional:
# the default; same arithmetic as `scoring.formula`, over
# { opportunity, effort, winnability, head_winnability, tail_winnability,
# total_vol, difficulty, cpc } — an unknown side reads as the neutral 1
gtmesh config set scoring.topic_selection 'opportunity * winnability'
# verdict `hold` below this (0 = off, the default) — i.e. no way in from EITHER side
gtmesh config set doctor.min_winnability 0.3topic performance [<value>]
The tune oracle — what’s working? — next to topic status (the build oracle). Joins the latest
performance snapshot (the committed bag pull performance
writes) against folded demand over the committed topical edges, and ranks the gap: per topic —
opportunity vs impressions / clicks / head position / CTR, with a trend against the previous window.
Read-only; performance never enters plan/apply — the report is where snapshot and graph meet.
The report reads one provider’s snapshot at a time — every metric and verdict is computed within
that provider’s frame (a Bing #3 is not a Google #3; each site’s CTR curve is its own baseline).
--source picks the frame; unset, it defaults to gsc when it has a snapshot, else the one
provider that does. Comparing frames is the diagnostic: a topic slipping in one engine points at
that engine’s ranking systems; slipping in both points at the content.
Each topic gets a verdict (the first that applies), a why, and a fix pointer:
| Verdict | When | Fix |
|---|---|---|
| decaying | Cluster position dropped ≥ thresholds.decay_threshold vs the previous window | Refresh/improve the slipping pages |
| striking-distance | Head position inside thresholds.striking_distance [low, high] with impressions ≥ its floor | Improve the apex — the head is within reach |
| ctr-gap | Ranking (position ≤ pos_max) but CTR under k × the site’s own CTR-at-that-position curve (a curve bucket only counts as a baseline with ≥ min_bucket impressions) | Rework titles/meta — the ranking isn’t converting to clicks |
| apex-unbuilt | The cluster has search data, but every hub/pillar for the topic is committed yet unpublished | Publish the topic’s home — the fix line names the exact gtmesh page promote /<home> --to queued |
| head-unranked | The cluster has ≥ impressions_floor impressions (and a live apex), but the head term — including its bare plural — has no ranking row | Point the apex at the head term — it isn’t ranking for it yet |
| no-data | The topic has pages, but no snapshot row matched them | Wait for impressions, or check the pages are indexed |
| unbuilt | Demand-bearing topic with no apex and no members | topic add --apex / topic fill |
| served | Has data, none of the above | Leave it alone |
Rows sort by verdict priority (the order above), then opportunity — effort goes where the gap is.
The thresholds come from the thresholds: config block; the CTR baseline is the site’s own
curve, derived from the snapshot’s query rows. Give a <value> to deep-dive one topic per page
(apex + members, each with its clicks / impressions / position / delta). --json via the standard
global flag. No snapshot yet → the report points at gtmesh pull performance and exits 0.
To see this report rather than read it, open gtmesh ui — the Dashboard renders the
site-wide story and the Topics tab tells each topic’s, over the same snapshot and verdicts.
| Argument / option | Description |
|---|---|
[value] | A single topic to deep-dive per page (omit for the ranked dashboard) |
--source <p> | The performance provider to read (gsc | bing | …). Default: gsc when it has a snapshot, else the one provider that does. An unknown name errors naming the providers with snapshots |
topic fill <value>
The act step — the follow-through for topic status’s “build the cluster”. It reports a topic’s build-out
across two dimensions:
- CONTENT — a CLI→skill bridge (like
harvest, the CLI is LLM-free): it grounds theharvestskill with the topic’s context and prints the prompt to propose the member pages (guides / glossary / comparisons). You author the keepers withgtmesh page add --parent-topic <value>. - COMMERCIAL — the engine names the topic’s uncovered commercial demand: the commercial/transactional
keywords (from the pull) that restate the topic’s head/seed terms and that no page covers. It can’t
map these to your products (that’s your catalog), so the human authors the product/commercial pages
(
gtmesh graph upsert <id> --label product --prop category=<value>). Setdoctor.min_commercial_volumeto filter low-volume noise. A well-built topic shows none (its commercial demand is already covered). The commercial signal is acommercial/transactionalintent tag; a keyword with no intent data (an exact-metrics (--pull overview) pull omits the intent column) falls back to a positive CPC — advertisers bidding is itself the buyer signal. Each surfaced keyword prints why it counts (the signal + the topic term it restates), and when none surface, the output shows the match terms + a drop funnel (how many keywords belonged, were already covered, weren’t commercial, or fell below the floor) so a0is debuggable rather than opaque — a common0cause is a stale bag, so re-rungtmesh pull demand --topic <value>.
| Argument | Description |
|---|---|
<value> | The topic to fill (errors if it doesn’t exist; recommends heading it with --apex if it has no apex) |
topic add <value>
Author a topic. Two modes:
Bare / root — author (or merge into) a topic node. With --root it becomes a pull target carrying its
seed_terms. The node is stamped carried: true, so it survives every apply even before any apex or page
binds to it. Re-running with new --seed updates the same topic.
Because the write MERGES, --root is also the reclassify verb: run it on a topic you already have (a
category or industry topic with a hub) and the pull config is overlaid without disturbing its demand,
surviving every apply. That is the “which of my topics deserve their own pull?” pass — e.g. promoting
beverage once you decide to cultivate it.
Apex (topic-first authoring) — with --apex, mint the topic and its apex page in one atomic op, born
together: the apex’s primary_keyword is the topic head. This is the topic-centric counterpart to
page add --role hub|pillar (which still works) — you name the topic, not the page. It mints
topics/<value> + the HUB_OF/PILLAR_OF edge; the slug defaults to /<section>/<value>. Add --root --seed to also make it a pull frontier.
| Argument / Option | Description |
|---|---|
<value> | The topic value (slug-normalized, e.g. submersible-pump) |
--root | Make it a root topic — a pull target refreshed every cycle (requires at least one --seed); promotes an existing topic too |
--seed <term> | A pull head (repeatable; e.g. --seed "submersible pump" --seed "sub pump") |
--exclude <substr> | A per-topic noise rule layered onto the global excludes at pull time (repeatable) |
--head <kw> | (apex) The topic head keyword → the apex page’s primary_keyword (required with --apex) |
--apex <page-type> | Mint an apex page of this type for the topic (born together) |
--section <s> | (apex) The apex page’s section (required with --apex) |
--as <role> | (apex) The apex role: hub | pillar (default hub) |
--kind <k> | (apex) The entity’s domain kind (category/manufacturer/…), passed to the apex page |
--slug <slug> | (apex) Override the derived apex slug (default /<section>/<value>) |
# author the "multistage-pump" topic + its category hub, born together, and make it a pull root:
gtmesh topic add multistage-pump --head "multistage pump" --apex category-hub \
--section categories --kind category --as hub --root --seed "multistage pump"topic unroot <value>
Demote a root: strip its pull-config (root/seed_terms/exclusions) so pull stops pulling it. The
topic itself stays (with its demand + memberships) — a pure-root topic with no pages becomes an empty
topic you can gtmesh graph delete.
stage — triage pulled demand before it feeds the build
A pull with --stage lands in the git-ignored staging area (.gtmesh/staging), which plan ignores.
stage works that area: inspect it, then admit a per-term slice into the committed bag — so you
can explore the demand landscape while shaping the config without flooding the graph, and admit
exactly the terms you keep without a second provider pull.
stage list
Print staged (not-yet-admitted) demand — term, volume, cluster — with a cheap resolves-to-entity
hint (→ <entity> / → unresolved) so you can see would this fold or strand? before admitting. A
pure entity resolve against the mesh’s entities; no plan round-trip.
stage admit
Promote a per-term slice from staging into the bag (data/raw/) as a new immutable file, then remove
it from staging. It catalogues on the next plan. One bag file per (adapter, cluster).
| Option | Description |
|---|---|
--keyword <kw> | The workhorse — repeatable. Admit exactly this keyword. Admit the keepers your judgment picked (--keyword "sump pump" --keyword "food grade pump") — no re-pull for the subset |
--match <pattern> | Admit staged terms matching a substring or /regex/ |
--min-vol <n> | Admit staged terms with at least this volume |
--all | Admit everything staged |
Selectors combine as: explicit --keyword always included, plus any term passing all provided
--match/--min-vol filters. A --keyword that isn’t staged is a hard error (a named keeper can’t
silently vanish); a selection matching nothing is a loud no-op.
stage drop
Discard staged terms without admitting them (the “throw it back”). It takes the same four selectors
as admit, declared once so they read and behave identically; --all drops everything staged.
Plan & apply — reconcile desired state
The core loop. plan is read-only; apply is the only writer. See the
walkthrough and tuning.
plan
Deterministic read-only diff; prints the plan and writes the planfile.
| Option | Description |
|---|---|
--out <file> | Override the plan.json output path |
--warn-detail | List every occurrence of an advisory class that reports as a count (retired-demand-suppressed) |
plan never changes anything — it computes the same diff apply will enact, so you can
run it as often as you like. The planfile is git-ignored.
apply
Preview the plan, confirm, then execute it (idempotent; promote pages first to build them).
| Option | Description |
|---|---|
--yes | Skip the confirmation prompt (required in a non-interactive shell) |
--force | With 0 pending actions, still regenerate graph/{nodes.jsonl,edges.tsv} + the manifest from committed state — to adopt an engine emission change (a node-prop rename, a dropped edge shape) that isn’t a page action. Idempotent (byte-identical if already current). |
--warn-detail | List every occurrence of an advisory class that reports as a count (retired-demand-suppressed) |
apply is Terraform-style: it shows the intended actions and prompts Proceed? [y/N]
before writing. It only acts when there’s a difference, so it’s always safe to re-run.
Before the prompt it warns once when the shared schema base (schemas/common.schema.yaml) is
pending an upgrade — the bundles it restamps are then judged by a stale contract. gtmesh upgrade
refreshes it.
--force — adopt an engine change with no pending work. A no-op apply (0 actions) doesn’t
rewrite the committed graph, so an engine-side emission change (e.g. the class→kind node-prop
rename, or a dropped redundant edge) wouldn’t land until the next content-changing apply.
gtmesh apply --force regenerates the committed graph + manifest from state right away. It’s
deterministic — if the graph is already current, the output is byte-identical (zero diff).
_brief is DERIVED — apply re-bakes it for the pages a writer is about to read. The page’s
_brief block is a projection of its node’s brief, so apply owns it and refreshes it wherever the
on-disk block differs — including on an apply with 0 pending actions. A bundle with no _brief
(hand-authored outside the scaffold path, or scaffolded before _brief existed) always gains one. A
stale _brief is re-baked when the page is not yet built, or has been re-opened by
page recreate / page amend — this is how a demand refresh reaches the writer.
A built page nobody has re-opened keeps its _brief: that block is the assignment its body was
written against, and it is the only record of it (built_brief_hash is a hash). gtmesh doctor’s
built-drift reads it to say which way the brief moved, and page recreate reads it to
refuse a re-author that would drop answers the body still carries.
Nothing else in the bundle is touched and body_hash excludes _brief, so no hash and no status
moves, and no body is rewritten. It is idempotent: an in-sync bundle is not rewritten, so a second
apply produces no diff. Reported as N brief(s) refreshed in the summary plus a
refreshed _brief: <slug> line per page.
apply has no --prune flag — a removed identity is tombstoned (id never reused, authored
edges never dangle); edit the graph to retire it.
No rebuild verb
There is no rebuild command. The committed graph is a reviewable Git artifact, so a reset is
just git reset (or checking out an earlier commit of graph/) — use Git.
Lifecycle — drive pages through their states
Everything you do to a page: author it (add, rehome) and move it through the lifecycle. The
lifecycle verbs set a page’s status; they are deliberate human edits to the committed graph. See
lifecycle.
page add
Author a fixed (seed) page — a hand-authored input node that persists across plan/apply. It
inserts the node (and the topic it heads or joins) directly into the committed graph; the
next apply builds it. Use it to stand up a new cluster without hand-editing the graph — see
Add a new category.
| Option | Description |
|---|---|
--slug <slug> | The page URL slug (required) |
--keyword <kw> | The primary keyword (required) |
--section <section> | The section / URL root (required) |
--type <page_type> | The page type (required, e.g. category-hub) |
--parent-topic <t> | The topic the page belongs to. topics/<t> is always ensured — a member joins it, an apex (--role) heads it |
--role <hub|pillar> | Make this page the apex of --parent-topic: it owns the value, minting topics/<t> + a HUB_OF/PILLAR_OF edge. Omit for a member. One hub / one pillar per topic |
--kind <k> | The entity’s domain kind (category/manufacturer/…). Sets the topic’s axis and makes a new cluster resolvable so members bind to it |
--label <label> | An extra node label (repeatable) — parity with graph upsert |
--prop <key=value> | An extra node prop, value JSON-parsed (repeatable) — parity with graph upsert |
--intent <intent> | Search intent (e.g. commercial) |
--discriminator <d> | Identity discriminator |
page rehome <slug>
Home an existing orphan page into a topic — the inverse of demote --orphan and the actuation the
curation loop drives over the backlog pool match recommends against. Where page add authors a new page
(and errors if the slug exists), rehome repoints a page that’s already committed: it authors the page’s
topical edge onto the topic and mints topics/<value> if absent — no new page, no identity churn. Once homed,
the page has a topical edge, so it clears the gate and gtmesh page promote / gtmesh next can move it into the
build set.
| Option | Description |
|---|---|
<slug> | The slug of the committed page to home (required, e.g. /glossary/effluent) |
--parent-topic <value> | The topic to home it into (required). topics/<value> is minted if it doesn’t exist |
--role <hub|pillar> | Elect this page the apex of the topic (HUB_OF/PILLAR_OF); omit to home it as a member (MEMBER_OF). One hub / one pillar per topic |
page promote
Move pages forward in the lifecycle (sets status; never builds).
| Argument | Description |
|---|---|
[slugs...] | Exact slugs to promote (composes with the selector flags) |
page demote
Move pages backward in the lifecycle (sets status; never builds).
| Argument | Description |
|---|---|
[slugs...] | Exact slugs to demote (composes with the selector flags) |
Selectors (shared by promote and demote). Field predicates compose with AND;
--under is a graph walk:
| Selector | Selects |
|---|---|
--section <name> | Pages in a section |
--has <id|axis> | Pages that hold a relationship to that node — --has categories/rotary-lobe-pumps (the owner hub), --has application/cip (a bare value node). A bare axis (--has application) takes every relationship on it. Targets are node ids: gtmesh explain <slug> lists a page’s. |
--topic <value> | Pages that belong to that topic — its members and its apex. Takes the value (dosing) or the node id (topics/dosing); topics/ is gtmesh-owned, so either spelling names the same node. |
--page-type <id> | Pages of a page type |
--slug-prefix <str> | Pages whose slug starts with this prefix |
--status <state> | Scope to pages currently in this status |
--under <hub-slug> | A hub slug + everything reachable down the mesh from it |
--orphan | Pages that belong to no topic (no MEMBER_OF/apex edge), excl. structural nav pages — e.g. gtmesh page demote --orphan --to backlog to park the orphan cleanup set (see the topically-orphaned doctor check) |
--to <status> | Target status (default: queued for promote, backlog for demote) |
--dry-run | Preview the transition without writing |
next
Recommend which un-built pages to build this cycle — one ranked, paced list instead of
cross-referencing topic status + priority + promote by hand. It ranks the planned pool
(pages you intend to build but haven’t queued yet) in the order that establishes topical authority
fastest, and hands back a batch (“build these N”). Parked backlog pages (deliberately
scoped-out / out-of-supply demand) are excluded by default — --include-backlog adds them, ranked
after all planned.
The order is apex-first, highest-opportunity-topic-first, because authority pools at the apex — members route up to it, so a topic’s home must exist before its cluster:
- Apex of a topic with no built home yet — build the destination first.
- Member of a topic whose apex is already live — routes authority up to a real apex.
- Member whose apex is still pending — deprioritized (it would route to nothing).
Within a tier it sorts by topic opportunity, then page priority (unranked last), then slug.
A page’s topic is read from its topical edge — the topic it is the apex of (HUB_OF/PILLAR_OF),
else the topic it is a MEMBER_OF — the same read topic status does, so the two commands always agree.
A page belonging to several topics is named after the one it can earn from now: a topic whose apex is
already live before a bigger one whose home is still pending, then the largest opportunity (scope it with
--topic <value>, which matches any of its topics). A page with no topical edge at all is reported as
belonging to no topic and ranked last — home it with gtmesh page rehome.
Read-only by default: it prints the batch and a ready-to-run promote command. --promote actuates
it (transitions the batch to queued); then gtmesh apply scaffolds them for the writer.
Tuned by observed performance (--tune, opt-in). With --tune, next reads the latest
performance snapshot (the one gtmesh pull performance commits) and boosts pages whose topic
wears an actionable verdict, in priority order — decaying > apex-unbuilt >
striking-distance > ctr-gap > head-unranked (served/no-data/unbuilt apply no boost).
The boost re-orders within the status tiers only: a backlog page still never outranks a planned
one, apex-first and the pacing cap are untouched. Every boosted row’s why line names the signal
(”… is decaying — down 4.4 places since the last window”), the header names the snapshot it tuned
by, and --json carries each boosted row’s signal (verdict + note). Setting next.tune: true in
gtmesh.config.yaml (gtmesh config set next.tune true) makes tuning every run’s default;
--no-tune reverts one run. With no snapshot yet it says so and ranks on demand alone — next
still only recommends; --promote remains the sole actuator.
| Option | Description |
|---|---|
--limit <n> | Batch size — how many to build this cycle (default 10) |
--topic <value> | Scope the recommendation to one topic |
--section <name> | Scope the recommendation to one section |
--include-backlog | Also recommend parked (backlog) pages, ranked after all planned |
--tune | Let observed performance verdicts re-order the queue within status tiers (opt-in; config next.tune: true makes it the default) |
--no-tune | Rank on demand alone for this run (overrides config next.tune) |
--promote | Actuate: transition the recommended batch to queued (else advisory only) |
page retire <slugs...> / page unretire <slugs...>
retire tombstones a page whose demand has faded or folded away (status → retired) — the way to
actuate a propose-retire from gtmesh plan. Distinct from demote --to backlog: a backlogged page is a
live candidate the match/curation loop should still reconsider; a retired page is deliberately gone.
It leaves the build set and the match/curation pool, and — crucially — suppresses a re-minting demand
draft in plan, so a still-present keyword can’t silently rebuild it on the next pull. The node is
kept (a redirect to a successor still resolves). A tombstone is terminal: once a page is retired, plan
leaves it alone and never proposes its retirement again, so actuating a propose-retire clears it from the
human gate for good. Bringing it back is the deliberate unretire.
The decline is on the demand, not on one classification of it: the retired page’s head keyword is
declined across identities. Otherwise the tombstone would be trivially reversible — a page identity is
(parent_topic × page_type × discriminator), so adding a sections_map or page_types rule would
re-mint the same keyword under a fresh identity the tombstone doesn’t cover (a keyword declined as a typeless
stub reappearing as /glossary/<term>). An operator decline is not undoable by a routing rule. Only the
exact head is declined, never the secondaries, so a different phrasing keeps its own page ("gear pump"
retired doesn’t block "gear pump vs screw pump"), and a head a live page still owns is never declined —
retiring a duplicate (the common case: a typeless root dropped in favour of the real page, both carrying
the same head) leaves the survivor fully reconciled rather than starving it of its demand. When a rule would
have re-classified declined demand, plan warns rather than staying silent — so you can see the rule didn’t
take effect there.
Retiring a duplicate works, but merge is the verb you actually want — and it doesn’t exist yet. retire
says this demand is gone; a duplicate is this demand belongs to that page instead, which should redirect
the dupe onto the canonical page and fold its keywords in. Until that verb lands, retire is the right tool
and behaves correctly here; the tombstone just records a weaker fact than the one you meant.
unretire reverses it: status retired → backlog, so the page re-enters the parked pool as a curation
candidate (the topic gate + curation then apply) and the re-mint suppression lifts.
Use retire for demand-faded/folded cruft; use demote --orphan --to backlog for real demand that just needs
a topic. Both are non-destructive and reversible.
Because the tombstone is committed state, gtmesh ui keeps rendering it — dimmed and struck in the DB
table, hollow and dashed in the Graph — and hides it from the DB build queue until you select retired in the
status filter.
match
Recommend candidate topics for each topically-orphaned page (a demand page that belongs to no
topic yet — the population topically-orphaned surfaces and the topic gate parks in backlog). For every
orphan it ranks existing topics by IDF-weighted keyword overlap against each topic’s full member-keyword
extent (every member + apex page’s primary_keyword + secondaries), not just the topic head — so recall is
high. Each shared token is weighted by how rare it is across topics: the ubiquitous domain head (e.g.
pump in a pumps mesh, present in ~every topic) discounts to ~zero signal, while the discriminating token
(dosing, hygienic, slurry) dominates — so a single-vertical mesh doesn’t collapse to an alphabetical wall
of ties. Generic English/SEO stopwords (best, how, vs, …) are dropped. The score is the rank; shared
tokens are shown so you can eyeball the fit. An orphan whose only overlap is the domain head shows no
confident candidate rather than a false tie. Structural nav pages (home / section indexes) are exempt.
Advisory only — match never attaches anything. It hands a human/LLM curation step a ranked shortlist;
you decide and enact with gtmesh page add --parent-topic <value> (join an existing topic) or
gtmesh topic add <value> --apex … (mint a new one with its apex). Read-only + deterministic.
| Option | Description |
|---|---|
--limit <n> | Max candidate topics per orphan (default 5) |
page publish <slugs...>
review/ready → published.
| Argument | Description |
|---|---|
<slugs...> | Page slugs to publish |
Exits non-zero if any named slug did not transition (CI gate, as seal does); each one is named on
stderr with its reason. A mixed batch still publishes the eligible pages.
page recreate [slugs...]
Re-open built pages for a fresh body (re-author) → writing. The existing prose stays on disk until
the writer replaces it; planned/backlog and retired pages are skipped.
It refuses in two cases, both about losing prose you can’t get back. First, when a selected page’s
body still answers a question its brief no longer lists — the writer would work from the current brief
and drop those answers. Second, when the page’s on-disk _brief is not the block seal stamped
(an older release re-baked it, or it was hand-edited), so there is no record of what the body was
written to answer and nothing can be ruled out. Either way it names the pages; gtmesh page amend →
seal re-stamps with no re-write, and gtmesh plan’s question-fold advisories name the page each
question moved to. --drop-answers proceeds anyway.
--dry-run writes nothing, whether the pages came from a selector or an explicit list.
| Argument | Description |
|---|---|
[slugs...] | Exact page slugs to re-open (composes with the selector flags) |
| Flag | Description |
|---|---|
--section <name> | Pages in a section |
--has <id|axis> | Pages related to this node, or every relationship on an axis |
--topic <value> | Pages belonging to this topic |
--page-type <id> | Pages of a page type |
--slug-prefix <str> | Pages whose slug starts with this prefix |
--status <state> | Scope to pages currently in this status |
--under <hub-slug> | A hub slug + everything reachable down the mesh from it |
--yes | Actuate a selector match (an explicit slug list needs no confirmation) |
--drop-answers | Re-author even where answers would be lost, or where the sealed brief can’t be verified |
--dry-run | Preview which pages would re-open, without writing |
page amend <slugs...>
Re-open a sealed/published page for a light re-seal (after image placement) → needs_update.
| Argument | Description |
|---|---|
<slugs...> | Page slugs to amend |
page seal <slugs...>
Validate (schema + editorial lints) then writing → review, stamp built hashes.
The bundle must carry its _brief block: built_brief_hash is stamped from the node, so sealing without
one would claim the body answers an assignment that isn’t on disk. gtmesh apply bakes a missing block
(hash-neutral) — run it, then seal.
| Argument | Description |
|---|---|
<slugs...> | Page slugs to seal |
Like validate and apply, it warns once when the shared schema base
(schemas/common.schema.yaml) is pending an upgrade — the gate then ran against a stale contract.
gtmesh upgrade refreshes it.
page validate [slugs...]
Validate page YAML against schema + editorial lints.
| Argument | Description |
|---|---|
[slugs...] | Page slugs to validate (default: every scaffolded page) |
Run with no slugs to also check committed inputs (reference tables, discovery
seeds) against the engine’s column contracts. For the engine-consumed tables (entities —
reconstructed from the committed graph — and signals) this includes a config-derived per-column schema, so a bad enum
value, a missing required column, or a broken entity cross-reference is reported precisely
as row N, column X: …. An unescaped comma in a free-text cell (which makes a row wider
than the header) is reported as a widened-row error naming the file, the row, and the fix
(quote the cell) rather than a cryptic parser message.
The page lint also enforces symbolic refs in prose (see the
glossary): a literal internal URL in a body
(the mesh’s own domain, or a root-relative /path) is an error — link with
ref:page/<id> (a page by its stable id) or ref:links/<scope>/<n> (self-relative: your
page’s Nth up/down/siblings/across link) so the link re-resolves on rename /
re-ownership. A ref that doesn’t resolve — a ref:page/<id> for an unknown id, or a
ref:links index out of range — is also an error. External URLs are untouched. seal runs
the same checks, so neither can slip past.
Every per-type schema $refs the shared base (schemas/common.schema.yaml), so when that one
engine-owned file is pending an upgrade, validate says so once, up front: the results below it
were computed against a stale contract. gtmesh upgrade refreshes it. It’s the only file whose
staleness changes a command’s answer — everything else waits in doctor.
reference [table]
Print a reference table’s config-derived column contract — the columns, which are required, and
the allowed values for enum columns. It’s the schema helper for the signals classification input;
validate then checks the authored CSV against it. Entities are graph-authored
(gtmesh graph upsert / gtmesh page add), not a reference table, so there’s no entities schema
to describe.
| Argument | Description |
|---|---|
[table] | The table to describe (signals); omit to list describable tables |
Only the engine-consumed CSV input (signals) has a derivable contract. --json emits it as a
stable object.
An entity’s grounding facts are folded onto its page node — read them with
graph get <id> (they’re also folded into each page’s _brief.facts, which the
article-writer reads directly). Read-only by design; apply/seal/the lifecycle commands
remain the graph’s only writers.
Inspect — see the state of the mesh
status
Per-status counts, drift, the writer worklist, and the render set for --env.
| Option | Description |
|---|---|
--env <env> | Also show the renderable set for this environment |
--summary | Counts only (omit the writing worklist) |
When a built page has fallen behind the graph it prints drift — content: N, schema: N. The two
counts have two fixes: content (the page’s brief_hash moved — a changed assignment) is
re-stamped via gtmesh page amend → seal → publish when the body still answers it, or
re-authored via gtmesh page recreate → the writer → seal → publish when it doesn’t; schema (current_schema_hash
moved) is resolved by gtmesh apply, which restamps a body that still validates and moves the rest
to needs_update. doctor’s built-drift check names the pages behind each count. The
render projection is not drift — links re-resolve into site.manifest.json every build, so a
projection change is a manifest diff, not a page action.
It closes with a one-line footer when engine-owned files are pending — the count and the verb that
takes them (gtmesh upgrade), with doctor listing which. Only actionable drift
reaches this footer: a file you edited resolves through a merge, not an upgrade, so it stays in
doctor rather than repeating on every command.
doctor
Deterministic mesh-health lint — “linting for the mesh.” Runs a suite of structural-health checks over the plan diagnostics, the desired graph, and the raw keyword pull, then prints a report grouped by severity, each finding carrying a remediation pointer (the file to edit, the config knob, and a docs URL). It ends with a state-aware next-action footer (“what to run next”, read from committed state).
It also runs an input preflight: the same committed-input contracts gtmesh page validate checks.
Broken inputs (a mis-typed reference/seed column) are a likely root cause of the downstream
smells, so they surface first as an error and point you back at gtmesh page validate for the
per-row detail — treat the rest of the report as unreliable until inputs pass.
Read-only. It reads no page body content — the one thing it opens a bundle for is the engine-owned
_brief block, to check it is present and in sync with its node (the brief-drift check). Exit code
2 if any error-severity finding, else 0 (warnings don’t gate). Add --json for the machine
document.
Checks include: invalid committed inputs (the gtmesh page validate preflight), page-type imbalance,
unresolved keywords that look like real demand, catch-all / hub pollution, duplicate slugs / identity
health, thin near-duplicate fragments, zero-demand landings, consumer-intent noise on a B2B mesh, a
stale committed state vs. config (you changed config but didn’t re-apply), _brief drift
(error: a bundle whose _brief is missing — the graph believes the writer had an assignment that
isn’t on disk — or whose _brief hash no longer matches the node’s brief_hash, so the writer is
reading a stale assignment; either way built_brief_hash can’t be stamped correctly at seal, and
gtmesh apply re-bakes it), built drift (the
built-drift check, the same signal gtmesh status counts: warn for a page whose brief_hash
moved after it was sealed — gtmesh page amend → seal → publish re-stamps a body that still
answers the brief, gtmesh page recreate → the writer → seal → publish re-authors one that
doesn’t; reported as two findings, since plan proposes a derived page as rewrite (spec-change)
while an authored one is pinned (noop (authored-pinned)) and reaches no plan action; info for
one whose current_schema_hash moved — gtmesh apply restamps a body that still validates and moves
the rest to needs_update),
silently-defaulted intent
(a large share of demand-derived pages whose intent was guessed — no provider tag, no
section_intent pin — so it fell to the global default; fix by pulling demand before classify or setting
classification.default_intent), no-hubs, two dual-parent up-link checks (#141): a
dropped axis (a page whose entity declares two domain axes — e.g. category + manufacturer —
but whose page carries only one, because a less-specific class won the entity_kind_priority tiebreak
and the other axis was silently dropped) and a reachable hub unlinked (a page that shares a cluster
axis with an eligible owner-hub — a manufacturer/brand hub — it doesn’t link up to, usually a missing
hub_of(<axis>) rule in link_rules), and an excluded-target inbound-link check (#160: a page a
link_rules exclude_as_target rule marks as a link distributor — the glossary — that nonetheless
receives in-content inbound links; re-plan + apply to clear the stale edges), and two
topic-authority checks (the topic head belongs to its apex): no-apex-page-claims-head (a
non-apex page — a member or an orphan — whose keyword equals a topic’s head, cannibalizing the apex;
retarget it to a longer-tail variant) and apex-keyword-equals-topic-head (a topic owned by two
apexes — a hub and a pillar — whose keywords disagree, so apply picks the head arbitrarily; converge
them on one head), and a topic-coverage check root-topic-uncovered (a root topic — a pull
frontier you declared — that no built page covers; author its apex with gtmesh topic add --apex, harvest
its cluster, or gtmesh topic unroot it — ranked by opportunity so you build the valuable ones first), and a
topical-coverage check topically-orphaned (a built content page that belongs to no topic — no
MEMBER_OF and not an apex — so its authority ladders up to nothing; give it a topic by matching an existing
one (gtmesh page add --parent-topic) or minting one (gtmesh topic add --apex), demand-ranked so the
real gaps come first; structural nav pages are excluded, and a page whose demand faded onto another may be a
propose-retire in gtmesh plan instead), and a config-hygiene check dead-config-keys
(warn: keys in gtmesh.config.yaml the engine has no home for — the schema accepts them
silently, so a typo, or a key an older release read and this one doesn’t, parses and validates and
does nothing; one finding lists every dead path, and gtmesh config unset <path>
removes one. Keys you define yourself — domain axes and facets, environment names, seed clusters,
discovery classes, image registers — are your vocabulary and are never flagged). Every
threshold is config-driven (doctor: in gtmesh.config.yaml), so the engine’s checks name no domain.
The report closes with the observation checks — what the three observation channels
(performance, links, AI visibility) say the mesh needs, so doctor routes the operate cadence.
Three staleness checks fire when a channel’s snapshot has aged past its cadence: knob and
name the exact refresh verb: stale-performance-snapshot (warn, cadence.performance_days,
default 7 → gtmesh pull performance), stale-links-snapshot (info, cadence.links_days,
default 30 → gtmesh pull links), and stale-ai-snapshot (info, cadence.ai_days,
default 30 → gtmesh pull ai) — a mesh that never pulled a channel hears nothing about it.
Five signal checks read the fresh data: topics-decaying (warn: topics whose position
dropped vs the prior window, worst by opportunity — start the tune lap with
gtmesh topic performance <value>), apex-unbuilt-earning (warn: clusters earning
impressions while the topic’s home is still unbuilt — publish it first: promote it directly, a parked home never enters the next queue),
authority-deficit (info: a topic at striking distance or missing its head whose apex holds
under cadence.apex_share_floor — default 0.5 — of the cluster’s referring domains, or none;
links are the lever — fires per topic only when deep links exist somewhere on the site),
homepage-heavy-authority (info: the site has referring domains but effectively none reach a
topic page — one site-level finding instead of per-topic noise: deep links to the hubs are the
growth lever).
Two working-memory checks read the operator’s notes (OPERATOR.md, operator/**):
operator-notes-oversized (warn: a notes file past doctor.max_notes_lines — default 400, 0 =
off — no longer fits a single read, so a fresh session gets its oldest half with nothing to say so;
rotate history into operator/journal/<day>.md and promote what stays true to
operator/decisions.md) and legacy-operator-notes (warn: a root NEXT-STEPS.md mixes rules,
decisions and history in one file — ask the operator skill to run its migrate-next-steps routine
to split it). Both stay silent on a mesh with no notes. See
Working memory.
One check answers “is an upgrade due?” — engine-files-pending (warn), keyed on what
actually DIFFERS rather than on the version number (a release often ships no scaffold change, so a
version comparison nags meshes for which nothing moved). It buckets an upgrade dry run by action:
actionable — engine-owned files you haven’t edited that the bundle has moved on (updated) or
that this mesh doesn’t have yet (added), which a plain gtmesh upgrade applies in place;
informational — engine files you edited (modified) and project-owned files whose engine
default moved (default-available), which need a hand-merge, so they’re reported here and nowhere
else. Since every skill is engine-owned, gtmesh upgrade is how an engine fix reaches a live mesh.
| Option | Description |
|---|---|
--json | Machine-readable findings (global flag) |
explain [slug]
No argument — print a generated, always-in-sync Markdown view of the mesh structure —
what it ranks for, its sections, domain axes, the page-type → section/role/intent table, and
markets — computed from gtmesh.config.yaml so it can never drift. Prints to stdout; pipe it
to a file when you want an artifact:
gtmesh explain > strategy.mdThe business why (intent shapes, why an apex is source-less, dual-parent
rationale) is judgement, not derivable, and lives in foundation/brand.md.
With a page slug or id — explain one page from the committed graph: its
section, derived role and intent, the topics it’s a member of (MEMBER_OF) and their apex owners, which
hubs/pillars it routes up to and why (each topic’s routing weight, and any links.up caps
that trimmed a weaker parent), its HAS_<axis> domain edges, its siblings/across, and any
ALIAS_OF aliases whose equity flows into it.
gtmesh explain /integrations/hubspotRead-only. Needs committed state (gtmesh apply first). Add --json for the structured object.
graph find <label>
Every page carrying a label — the structural page, a page_type label (e.g. product), or an
axis label a hub gained (e.g. manufacturer) — slug-sorted with its page_type, role and status.
Read-only; needs committed state. --json for the machine document.
gtmesh graph find manufacturer # all manufacturer-labelled pagesgraph aliases <page>
Equity through aliases — the url nodes that ALIAS_OF the live page (its redirected URLs),
with their DR/refdomains. Takes a page slug or node id. Read-only; needs committed state.
gtmesh graph aliases /products/sru # redirected URLs feeding this pagegraph get <id>
Read one committed node by its id — its labels + props (including a page’s folded entity
facts: name, source_url, notes, aliases) — as JSON. The graph-native way to read a node’s
grounding data — the article-writer reads a page’s entity facts this way. Errors if the id isn’t
found.
gtmesh graph get products/alfa-laval-sru # a page id looks like this; find it in a page's _brief.idRead-only; needs the committed graph (graph/nodes.jsonl). --json for the machine document.
graph upsert <id> · delete · link · unlink
The editor verbs over the committed graph files (graph/nodes.jsonl + graph/edges.tsv). Every
mutation is write-then-validate: an edit that would break the graph (a dangling edge, a duplicate id)
is rejected and nothing is written. Author entities through these, never by hand-editing the files.
gtmesh graph upsert products/alfa-laval-sru --label product --prop name="Alfa Laval SRU"
gtmesh graph upsert topics/dosing --label topic --new # fail rather than merge into an existing node
gtmesh graph link products/alfa-laval-sru MEMBER_OF topics/dosing
gtmesh graph unlink products/alfa-laval-sru MEMBER_OF topics/dosing
gtmesh graph delete products/alfa-laval-sru # the node + every edge touching it| Verb | What it does |
|---|---|
upsert <id> | Add or merge a node — labels unioned, props merged |
delete <id> | Remove a node and every edge touching it |
link <from> <type> <to> | Add a typed edge (both endpoints must exist) |
unlink <from> <type> <to> | Remove a typed edge (the nodes are untouched) |
upsert options:
| Option | Description |
|---|---|
--label <label> | A node label (repeatable) |
--prop <key=value> | A node prop; the value is JSON-parsed, else kept a string (repeatable) |
--props-json <object> | The whole props object as JSON — --prop overrides it |
--new | Require the id to be unused: fail rather than merge into an existing node |
ui
Open the mesh’s local, read-only control room. gtmesh ui starts a small loopback dev-server
(127.0.0.1 only — no external network), serves a viewer over it, and (on a TTY) opens your browser
on the Dashboard. Five tabs: Dashboard (the site’s Search-Console story — tiles, trends, where
to focus) · Topics (every topic ranked, with a plain-language health chip over the
topic performance verdicts) · Site (the compiled site tree) ·
Data (nodes as browsable records) · Graph (the whole graph, force-directed). It reads the
committed graph artifacts (graph/nodes.jsonl, graph/edges.tsv) plus the latest performance
snapshot; it never writes committed state (read-only, like doctor), and it is not part of
the deterministic pipeline — determinism lives in the model it renders.
It runs until you press Ctrl-C; there is no committed output (scratch stays under the git-ignored
.gtmesh/ui/). The server watches the artifacts — the performance bag included — and live-reloads
as you apply/seal or pull a fresh snapshot in another terminal. With no snapshot yet, Dashboard
and Topics open on an empty state that walks through the
pull performance setup.
The Data table sorts by priority (page nodes) and opportunity (topic nodes) as well as
id/label/slug/status; a node carrying no value for a ranking column sorts last, never as zero.
Retired pages (page retire) stay visible everywhere they exist — dimmed and struck in the
Data table, hollow and dashed in the Graph — but the Data list, being a build queue, holds them back
until you select retired in the status filter (permalinked as #db?status=…,retired).
gtmesh ui # serve on a free port and open the browser on the Dashboard
gtmesh ui --view topics # land on the topic scoreboard instead
gtmesh ui --port 5175 # pin the port
gtmesh ui --no-open # serve without opening a browser (e.g. remote/headless)| Option | Description |
|---|---|
--view <view> | Initial tab: dashboard | topics | db (Data) | graph | manifest (Site) (default dashboard) |
--port <n> | Pin the dev-server port (else a free one is chosen and printed) |
--open / --no-open | Force opening / not opening the browser (default: open on a TTY) |
--env <env> | Initial Site-tab render-set environment (local | dev | prod) |
render-manifest
Emit the renderable slug set for an environment.
| Option | Description |
|---|---|
--env <env> | (required) Environment (e.g. local | dev | prod) |
--out <file> | Override the manifest output path |
Consume — generate the content-types package
build
Emit site.manifest.json — the SSG’s re-resolved projection: the link tree (both up-scopes,
siblings, across) plus the redirect table and the per-environment render sets. Read-only over the
committed graph; re-resolved on every run, so a link_rules/links.weights change shows up as a
manifest diff with zero content rewrites (the links are un-baked, not stamped onto pages).
| Option | Description |
|---|---|
--check | Re-derive and compare to the committed manifest (CI gate); exits non-zero on drift, never writes |
--out <file> | Override the manifest output path (default: <project>/site.manifest.json) |
A normal apply refreshes the manifest for you, so you rarely run build by hand —
reach for it in CI (gtmesh build --check) to prove the committed manifest matches a fresh
re-resolve. Needs committed state (gtmesh apply first).
types
Generate the SSG-facing TS content-types package from schemas/ (--check verifies sync,
no write).
| Option | Description |
|---|---|
--check | Verify the committed package matches the schemas (CI gate); exits non-zero on drift |
--dir <path> | Override the package directory (default: config.codegen.dir) |
This emits a self-contained, buildable TypeScript package your website (or any TS consumer)
compiles against. Wire gtmesh types --check into CI so the consumer’s types can’t drift
from the content contract.
Maintain — keep the engine fresh
upgrade
Re-apply engine-owned scaffold files (refresh; never clobbers your edits or project files).
| Option | Description |
|---|---|
--dry-run | Report the plan without writing anything |
--with-defaults | Also write changed project-file defaults as *.default for hand-merging |
Engine-owned files are refreshed if you haven’t touched them; if you have, the new version
lands as <file>.default.<ext>. Your content in project-owned files (config, reference data, your
per-type schemas, templates) is never overwritten. It also runs two versioned, once-only migration
chains: schema migrations over your committed state (recorded in the committed
graph/meta.json) and config-format migrations over gtmesh.config.yaml (recorded in its
config_version key). Config migrations are structural — a key rename, a dead key dropped — and
edit the file in place with every comment and the key order intact; run gtmesh plan before and
after and you get the same answer. Both chains are skipped under --dry-run, catch a mesh up from any
age in one pass, and fire each step exactly once.
A missing .gtmesh/manifest.json (the upgrade baseline) is recreated from the bundle and the
run continues — under --dry-run too; a directory with no gtmesh.config.yaml still errors.
Idempotent. See Installation for the full upgrade workflow (and how to
bump the CLI itself).
migrate (adopt an existing content repo) is registered but not yet implemented — it
prints a notice and exits non-zero. A greenfield project doesn’t need it.