Skip to Content
CLIReference

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):

OptionDescription
--project <dir>Project directory (the scaffolded mesh repo). Default: .
--jsonMachine-readable output
--quietSuppress non-essential output
--no-colorNever 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 --help lists every verb and ends with a Docs: link to this reference.
  • gtmesh <command> --help prints a verb’s synopsis and flags, ending with a Docs: link to that command’s reference section.
  • Adding the global --json renders help as a single machine-readable document — gtmesh <command> --help --json returns { name, description, usage, docs, options, arguments, subcommands }, where docs is the command’s reference URL. Version-matched and offline; handy for tooling.

Scaffold

These get a project off the ground. See the walkthrough.

init

→ Full page

Scaffold a clean, generic mesh repo (placeholder config, empty reference CSVs, generic foundations/templates/schemas). Add a worked-example demo with --template <name>.

OptionDescription
--forceOverwrite existing files instead of skipping them
--template <name>Overlay a bundled demo/starter template on the generic base (e.g. acme)
--list-templatesList 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

→ Full page

Print how to run the harvest skill (entity-class term harvesting) for a configured class.

ArgumentDescription
[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.

OptionDescription
--resolvedPrint 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).

OptionDescription
--dry-runPrint 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 unchanged

config_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.

OptionDescription
--dry-runPrint 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 ].

OptionDescription
--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-runPrint 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.

OptionDescription
--dry-runPrint 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

→ Full page

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.

ChannelWhat it writes
pull demandThe keyword bag (data/raw/keywords/) — the one channel plan reads
pull aiAI-answer citation counts per provider (data/raw/ai/)
pull performanceSearch-performance snapshots + the index sweep (data/raw/performance/)
pull linksBacklinks, 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
OptionDescription
--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-runReport the planned pulls without spending API credits
--stageWrite 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):

  1. Create a service account in Google Cloud (IAM → Service Accounts) and download its JSON key.
  2. In Search Console → Settings → Users and permissions, invite the service account’s client_email as a Full user (a standard invite — revocable anytime, no Google credentials shared).
  3. Save the key as gsc-key.json at 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.json in .env.local overrides.)
  4. 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.property is set. The pull is exactly pull performance --source gsc, index sweep included (--no-index applies to this provider only — Bing has no sweep).
  • bing — configured when the adapters.bing block is present. Pulls Bing Webmaster Tools search performance for the site (GetPageStats + GetQueryStats + GetRankAndTrafficStats → the same pages / queries / daily snapshot files). Bing’s query stats are site-level, so its queries rows carry no ranking URL; positions come from Bing’s AvgImpressionPosition, and both report windows derive from the date-bucketed rows on the same clock conventions as gsc (current = the trailing adapters.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):

  1. Verify the site in Bing Webmaster Tools “Import from Google Search Console” is the fastest path when GSC is already set up.
  2. Settings → API accessAPI key — generate (or copy) your key.
  3. Export BING_WEBMASTER_API_KEY=<key> (e.g. in .env.local — it must never be committed; the key rides the environment, never config).
  4. Opt the provider in: gtmesh config set adapters.bing '{}' — the block’s presence is the opt-in. The site URL derives from site.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).

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 via site-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 stable date_from (default 2020-01-01; override with adapters.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 via site-explorer/all-backlinks aggregated per referring domain × target URL. domain is 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).
  • bing — configured when the adapters.bing block is present (the same opt-in and BING_WEBMASTER_API_KEY as the performance provider). One pull: GetLinkCounts, Bing’s per-page inbound-link counts from its own index — counts only, so its snapshot’s refdomains/url_rating cells 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 / OptionDescription
<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-runReport the planned pull without spending API credits
--stageWrite 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 / OptionDescription
<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-runReport the planned pull without spending API credits
--stageWrite to the git-ignored staging area, not the committed bag

topic

→ Root topics & the pull unit

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).

OptionDescription
--roots-onlyList 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 it

topic 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-pumps

topic 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 / OptionDescription
[value]A single root topic to refresh (omit to refresh every root)
--applyAlso apply the plan + show topic status (opt-in the graph write)
--dry-runPlan 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.

ArgumentDescription
[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 pulledgtmesh topic add --root + topic refresh
  • BUILD OR DROP — roots with no page → gtmesh topic add --apex (or topic 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 30000

topic 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:

VerdictWhenFix
buildThe topic has no apex — no home for authority to pool atgtmesh topic add <v> --head … --apex …
promoteBuilt, but not a root — its demand never refreshesgtmesh topic add <v> --root --seed "<head>"
investRooted with an apex — grow the clustergtmesh topic fill <v>
holdWinnability under doctor.min_winnabilityno 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.

OptionDescription
--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.3

topic performance [<value>]

The tune oraclewhat’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:

VerdictWhenFix
decayingCluster position dropped ≥ thresholds.decay_threshold vs the previous windowRefresh/improve the slipping pages
striking-distanceHead position inside thresholds.striking_distance [low, high] with impressions ≥ its floorImprove the apex — the head is within reach
ctr-gapRanking (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-unbuiltThe cluster has search data, but every hub/pillar for the topic is committed yet unpublishedPublish the topic’s home — the fix line names the exact gtmesh page promote /<home> --to queued
head-unrankedThe cluster has ≥ impressions_floor impressions (and a live apex), but the head term — including its bare plural — has no ranking rowPoint the apex at the head term — it isn’t ranking for it yet
no-dataThe topic has pages, but no snapshot row matched themWait for impressions, or check the pages are indexed
unbuiltDemand-bearing topic with no apex and no memberstopic add --apex / topic fill
servedHas data, none of the aboveLeave 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 / optionDescription
[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 the harvest skill with the topic’s context and prints the prompt to propose the member pages (guides / glossary / comparisons). You author the keepers with gtmesh 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>). Set doctor.min_commercial_volume to filter low-volume noise. A well-built topic shows none (its commercial demand is already covered). The commercial signal is a commercial/transactional intent 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 a 0 is debuggable rather than opaque — a common 0 cause is a stale bag, so re-run gtmesh pull demand --topic <value>.
ArgumentDescription
<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 / OptionDescription
<value>The topic value (slug-normalized, e.g. submersible-pump)
--rootMake 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

→ Full page

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).

OptionDescription
--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
--allAdmit 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

→ Full page

Deterministic read-only diff; prints the plan and writes the planfile.

OptionDescription
--out <file>Override the plan.json output path
--warn-detailList 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

→ Full page

Preview the plan, confirm, then execute it (idempotent; promote pages first to build them).

OptionDescription
--yesSkip the confirmation prompt (required in a non-interactive shell)
--forceWith 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-detailList 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 classkind 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 amendthis 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

→ Full page

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

→ Full page

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.

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

→ Full page

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.

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

→ Full page

Move pages forward in the lifecycle (sets status; never builds).

ArgumentDescription
[slugs...]Exact slugs to promote (composes with the selector flags)

page demote

→ Full page

Move pages backward in the lifecycle (sets status; never builds).

ArgumentDescription
[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:

SelectorSelects
--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
--orphanPages 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-runPreview 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:

  1. Apex of a topic with no built home yet — build the destination first.
  2. Member of a topic whose apex is already live — routes authority up to a real apex.
  3. 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.

OptionDescription
--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-backlogAlso recommend parked (backlog) pages, ranked after all planned
--tuneLet observed performance verdicts re-order the queue within status tiers (opt-in; config next.tune: true makes it the default)
--no-tuneRank on demand alone for this run (overrides config next.tune)
--promoteActuate: transition the recommended batch to queued (else advisory only)

page retire <slugs...> / page unretire <slugs...>

→ Full page

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 retiredbacklog, 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 onlymatch 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.

OptionDescription
--limit <n>Max candidate topics per orphan (default 5)

page publish <slugs...>

→ Full page

review/readypublished.

ArgumentDescription
<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...]

→ Full page

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 amendseal 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.

ArgumentDescription
[slugs...]Exact page slugs to re-open (composes with the selector flags)
FlagDescription
--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
--yesActuate a selector match (an explicit slug list needs no confirmation)
--drop-answersRe-author even where answers would be lost, or where the sealed brief can’t be verified
--dry-runPreview which pages would re-open, without writing

page amend <slugs...>

→ Full page

Re-open a sealed/published page for a light re-seal (after image placement) → needs_update.

ArgumentDescription
<slugs...>Page slugs to amend

page seal <slugs...>

→ Full page

Validate (schema + editorial lints) then writingreview, 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.

ArgumentDescription
<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...]

→ Full page

Validate page YAML against schema + editorial lints.

ArgumentDescription
[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.

ArgumentDescription
[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

→ Full page

Per-status counts, drift, the writer worklist, and the render set for --env.

OptionDescription
--env <env>Also show the renderable set for this environment
--summaryCounts 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 amendsealpublish when the body still answers it, or re-authored via gtmesh page recreate → the writer → sealpublish 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 amendsealpublish re-stamps a body that still answers the brief, gtmesh page recreate → the writer → sealpublish 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.

OptionDescription
--jsonMachine-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.md

The 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/hubspot

Read-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 pages

graph 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 page

graph 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.id

Read-only; needs the committed graph (graph/nodes.jsonl). --json for the machine document.

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
VerbWhat 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:

OptionDescription
--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
--newRequire the id to be unused: fail rather than merge into an existing node

ui

→ Full page

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)
OptionDescription
--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-openForce opening / not opening the browser (default: open on a TTY)
--env <env>Initial Site-tab render-set environment (local | dev | prod)

render-manifest

→ Full page

Emit the renderable slug set for an environment.

OptionDescription
--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).

OptionDescription
--checkRe-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

→ Full page

Generate the SSG-facing TS content-types package from schemas/ (--check verifies sync, no write).

OptionDescription
--checkVerify 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

→ Full page

Re-apply engine-owned scaffold files (refresh; never clobbers your edits or project files).

OptionDescription
--dry-runReport the plan without writing anything
--with-defaultsAlso 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.

Last updated on