@canonry/canonry 4.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +117 -0
  3. package/assets/agent-workspace/AGENTS.md +88 -0
  4. package/assets/agent-workspace/USER.md +23 -0
  5. package/assets/agent-workspace/skills/aero/SKILL.md +72 -0
  6. package/assets/agent-workspace/skills/aero/references/aeo-discovery.md +161 -0
  7. package/assets/agent-workspace/skills/aero/references/memory-patterns.md +66 -0
  8. package/assets/agent-workspace/skills/aero/references/orchestration.md +67 -0
  9. package/assets/agent-workspace/skills/aero/references/regression-playbook.md +58 -0
  10. package/assets/agent-workspace/skills/aero/references/reporting.md +98 -0
  11. package/assets/agent-workspace/skills/aero/references/wordpress-elementor-mcp.md +223 -0
  12. package/assets/agent-workspace/skills/aero/soul.md +30 -0
  13. package/assets/agent-workspace/skills/canonry/SKILL.md +152 -0
  14. package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +200 -0
  15. package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +714 -0
  16. package/assets/agent-workspace/skills/canonry/references/google-business-profile.md +300 -0
  17. package/assets/agent-workspace/skills/canonry/references/indexing.md +163 -0
  18. package/assets/agent-workspace/skills/canonry/references/server-side-traffic.md +372 -0
  19. package/assets/agent-workspace/skills/canonry/references/wordpress-integration.md +61 -0
  20. package/assets/apple-touch-icon.png +0 -0
  21. package/assets/assets/BacklinksPage-CixBvw--.js +1 -0
  22. package/assets/assets/ChartPrimitives-CjX4mQCy.js +1 -0
  23. package/assets/assets/ProjectPage-C09hcd6v.js +7 -0
  24. package/assets/assets/RunRow-Bel7mWgu.js +1 -0
  25. package/assets/assets/RunsPage-wqKWA2Ei.js +1 -0
  26. package/assets/assets/SettingsPage-esOqUkW5.js +1 -0
  27. package/assets/assets/TrafficPage-V0iCV3l4.js +1 -0
  28. package/assets/assets/TrafficSourceDetailPage-DUeuOh2v.js +1 -0
  29. package/assets/assets/arrow-left-CVZPxpW6.js +1 -0
  30. package/assets/assets/extract-error-message-BWAlA7ea.js +1 -0
  31. package/assets/assets/index-LsyRFLMG.css +1 -0
  32. package/assets/assets/index-avJiga1P.js +210 -0
  33. package/assets/assets/trash-2-CA_L6uGC.js +1 -0
  34. package/assets/assets/vendor-markdown-6dwjPOKK.js +14 -0
  35. package/assets/assets/vendor-radix-CQ77EO2y.js +45 -0
  36. package/assets/assets/vendor-recharts-C9EZkgbP.js +36 -0
  37. package/assets/assets/vendor-tanstack-Be8JdV5M.js +1 -0
  38. package/assets/favicon-32.png +0 -0
  39. package/assets/favicon.svg +24 -0
  40. package/assets/index.html +25 -0
  41. package/bin/canonry-mcp.mjs +2 -0
  42. package/bin/canonry.mjs +2 -0
  43. package/dist/chunk-23DCY7DL.js +7030 -0
  44. package/dist/chunk-GIKNEXFE.js +12414 -0
  45. package/dist/chunk-GNMZTR6Y.js +35673 -0
  46. package/dist/chunk-Q7FFDQU4.js +5743 -0
  47. package/dist/cli.js +13344 -0
  48. package/dist/index.d.ts +260 -0
  49. package/dist/index.js +12 -0
  50. package/dist/intelligence-service-GM3RXE2F.js +7 -0
  51. package/dist/mcp.js +442 -0
  52. package/package.json +97 -0
@@ -0,0 +1,714 @@
1
+ # Canonry CLI Reference
2
+
3
+ The CLI is invoked as `cnry` (short form) or `canonry` — both ship with the `@canonry/canonry` npm package and behave identically. This reference uses `cnry`.
4
+
5
+ ## Server Management
6
+
7
+ ```bash
8
+ cnry init # interactive setup
9
+ cnry bootstrap # non-interactive setup from env vars
10
+ cnry start # start daemon
11
+ cnry stop # stop daemon
12
+ cnry serve # foreground mode
13
+ cnry serve --host 0.0.0.0 --port 4100
14
+ cnry serve --embed --embed-allow-origin https://app.example.com # read-only embed mode (#716)
15
+ cnry --version
16
+ ```
17
+
18
+ ### Read-only embed mode (#716)
19
+
20
+ Opt-in, OFF by default. Renders the dashboard "chromeless" (no nav/topbar/settings) so it can be iframed read-only, and emits a fail-closed framing contract. With `--embed` absent, served HTML + headers are byte-for-byte unchanged.
21
+
22
+ ```bash
23
+ cnry serve --embed --embed-allow-origin https://app.example.com [--embed-allow-origin https://b.example] [--embed-view overview]
24
+ cnry start --embed --embed-allow-origin https://app.example.com # daemon form (forwards flags to serve)
25
+ # Env equivalents (env overrides config.yaml `embed:`):
26
+ # CANONRY_EMBED=1 CANONRY_EMBED_ORIGINS=https://a.com,https://b.com CANONRY_EMBED_VIEWS=overview,project
27
+ ```
28
+
29
+ - **Framing:** emits `Content-Security-Policy: frame-ancestors <origins>` on the SPA document, **failing CLOSED to `frame-ancestors 'none'`** when no valid origins are configured (so a misconfigured embed is un-framable, not open to everyone). Origins must be bare `scheme://host[:port]` — paths, wildcards (`*.host`), and non-http(s) schemes are rejected. No `X-Frame-Options` is emitted (CSP is the single source of truth).
30
+ - **Read-only is server-enforced:** embed mode adds NO write surface; the read-only API-key gate (403 on POST/PUT/PATCH/DELETE) is unchanged.
31
+ - **Cross-origin auth caveat:** the `SameSite=Lax` session cookie is NOT sent in a cross-site iframe and the shipped bundle has no API key. v1 works for a **same-origin** embed (cookie flows) OR a self-hosted build with a read-only `VITE_API_KEY` baked in (then client-visible). Do not loosen the cookie to `SameSite=None`.
32
+
33
+ Production managed by PM2:
34
+ ```bash
35
+ pm2 status
36
+ pm2 logs canonry
37
+ pm2 restart canonry
38
+ ```
39
+
40
+ ## Project Management
41
+
42
+ ```bash
43
+ cnry project list # list all projects
44
+ cnry project create <name> --domain <url> --country US --language en
45
+ cnry project show <name> # project detail
46
+ cnry project update <name> # update project settings
47
+ cnry project delete <name> # delete a project
48
+ cnry project delete <name> --dry-run # preview cascade impact (GET /delete-preview) without writing
49
+ cnry status <project> # mention + citation summary + domain info
50
+ ```
51
+
52
+ ### Brand aliases
53
+
54
+ `spec.brandAliases: string[]` on the project (set via `cnry apply` or the dashboard) widens the mention detector. Use it when the answer text says "Meta" but the canonical brand is "Facebook", or for product variants ("AcmeCloud", "Acme Cloud", "AcmeCloud Pro"). Aliases are case-insensitive and match the same answer-text scan that powers `answerMentioned`.
55
+
56
+ ## Surgical Reads — `cnry get`
57
+
58
+ ```bash
59
+ cnry get <project> scores.mentionShare.value
60
+ cnry get <project> scores.mentionCoverage.value
61
+ cnry get <project> scores.citationCoverage.value
62
+ cnry get <project> insights[0].severity
63
+ cnry get <project> latestRun.status
64
+ cnry get <project> --from report scores.citationCoverage.value # pick a registered source
65
+ cnry get <project> <path> --format json # raw JSON output
66
+ ```
67
+
68
+ Resolves a dot/bracket path against the project's overview (default `--from overview`) or any registered source — `report`, `traffic`, `discovery`, etc. Returns the scalar (or sub-tree) at the path so an agent can lift a single number without pulling a 30 KB JSON payload. Use `--from <source> .` to see the available top-level keys for that source.
69
+
70
+ ### Locations
71
+
72
+ Projects support multi-region location context for geographically-aware sweeps:
73
+
74
+ ```bash
75
+ cnry project add-location <name> --label "NYC" --city "New York" --region NY --country US
76
+ cnry project locations <name> # list configured locations
77
+ cnry project set-default-location <name> <label>
78
+ cnry project remove-location <name> <label>
79
+ ```
80
+
81
+ ## Sweeps
82
+
83
+ ```bash
84
+ cnry snapshot "Acme Corp" --domain acme.example.com # one-shot sales snapshot
85
+ cnry snapshot "Acme Corp" --domain acme.example.com --md # save markdown report
86
+ cnry snapshot "Acme Corp" --domain acme.example.com --output report.md # custom path
87
+ cnry snapshot "Acme Corp" --domain acme.example.com --pdf # save PDF report
88
+ cnry snapshot "Acme Corp" --domain acme.example.com --format json
89
+
90
+ cnry run <project> # sweep all configured providers
91
+ cnry run <project> --provider gemini # single provider only
92
+ cnry run <project> --query "alpha" --query "beta" # scope sweep to a subset of tracked queries (repeatable)
93
+ cnry run <project> --wait # block until complete
94
+ cnry run <project> --location <label> # run with specific location context
95
+ cnry run <project> --all-locations # run for every configured location
96
+ cnry run <project> --no-location # explicitly skip location context
97
+ cnry run <project> --probe --provider openai --query "..." # operator/agent test run — snapshot is inspectable but EXCLUDED from dashboard, analytics, intelligence, report, and notifications. Use for verification / "did this fix work?" / regression hypothesis testing.
98
+ cnry run --all --wait # all projects
99
+ cnry run cancel <project> [run-id] # force-cancel stuck runs
100
+ cnry runs <project> --limit 10 # list recent runs (includes both real and probe runs; filter on `trigger` if you only want one)
101
+ cnry run show <id> # show run details
102
+ ```
103
+
104
+ Run statuses: `queued` → `running` → `completed` / `failed` / `partial`
105
+
106
+ `partial` = some providers failed (usually rate limits) — successful snapshots are still saved.
107
+
108
+ ### Probe vs real runs
109
+
110
+ | Trigger | Source | Feeds dashboard/analytics | Runs intelligence | Fires notifications | Wakes Aero |
111
+ |---|---|---|---|---|---|
112
+ | `manual` | `cnry run <project>` | ✅ | ✅ | ✅ | ✅ |
113
+ | `scheduled` | cron schedule | ✅ | ✅ | ✅ | ✅ |
114
+ | `config-apply` | `cnry apply` after queries change | ✅ | ✅ | ✅ | ✅ |
115
+ | `backfill` | `cnry backfill ...` | partial (historical) | ✅ | — | — |
116
+ | **`probe`** | `cnry run --probe ...` | ❌ | ❌ | ❌ | ❌ |
117
+
118
+ Use `--probe` whenever you're testing on your own initiative — verifying a fix landed, reproducing a regression, sanity-checking a query — rather than producing data the user/dashboard will consume.
119
+
120
+ `snapshot` does not create a project or write to the DB. It generates category queries, runs providers, and produces a report for prospecting.
121
+
122
+ ## Mention + Citation Data
123
+
124
+ Two independent signals per (query × provider): **mention** (`answerMentioned` — brand named in the answer text; the **primary** read) and **citation** (`cited`/`citedDomains` — domain in the grounding sources; **secondary**). Read mention first. Never compute one from the other; never coerce `answerMentioned` null → false (null = "not checked").
125
+
126
+ ```bash
127
+ cnry evidence <project> # per-query [C/c][M/m] cell + Mentioned: X / Y / Cited: X / Y
128
+ cnry evidence <project> --format json # JSON output
129
+ cnry history <project> # audit trail
130
+ cnry export <project> --include-results # export as YAML
131
+ cnry backfill answer-mentions # recompute answerMentioned (primary) from stored answers (honors brandAliases)
132
+ cnry backfill answer-mentions --dry-run
133
+ cnry backfill answer-visibility # recompute citationState (secondary) from stored answers
134
+ cnry backfill answer-visibility --dry-run # preview which snapshots would change
135
+ cnry backfill insights <project> # recompute insights for completed runs
136
+ cnry backfill insights <project> --since 2026-04-01 --dry-run
137
+ ```
138
+
139
+ Output uses a two-glyph cell per (query × provider): `[C/c][M/m]` — uppercase = present, lowercase = absent, `–` = no snapshot. **C/c = cited** (secondary), **M/m = mentioned** (primary). Always print the legend before the table; never collapse the two signals into one cell.
140
+
141
+ ```
142
+ Legend: [C/c][M/m] C=cited c=not-cited M=mentioned m=not-mentioned –=no snapshot
143
+
144
+ [C][M] acme corp ny ← mentioned AND cited
145
+ [c][M] best crm for smb ← mentioned, not cited (mention win, citation gap)
146
+ [C][m] crm pricing ← cited, not mentioned (citation without share of voice)
147
+ [c][m] free crm tools ← neither
148
+ ```
149
+
150
+ Summary: `Mentioned: X / Y` (primary) and `Cited: X / Y` (secondary) are reported independently — a query can be one, both, or neither.
151
+
152
+ ## Reports
153
+
154
+ ```bash
155
+ cnry report <project> # write canonry-report-<project>-YYYY-MM-DD.html
156
+ cnry report <project> --period 14 # time window: 7|14|30|90 days (default 30) — scopes GSC/GA/server-activity + period-over-period deltas
157
+ cnry report <project> --output dist/aeo.html # custom path
158
+ cnry report <project> --format json # raw report payload to stdout
159
+ ```
160
+
161
+ One-command client-facing AEO report. Bundles the latest visibility sweep, competitor landscape, AI citation sources, GSC + GA4 performance, social and AI referrals, indexing health, citations trend, prioritized insights, and recommended next steps into a self-contained HTML file (inline CSS + SVG charts, no network dependencies). Backed by `GET /api/v1/projects/<name>/report` and the `canonry_report` MCP tool.
162
+
163
+ Behavior to know when narrating numbers from the report:
164
+ - `executiveSummary.citationRate` is **per-query** — `citedQueryCount / totalQueryCount`, with a query counted as cited if any provider in the run cited it. The rate is invariant to provider count, so a gemini-only run and a 4-provider run can be compared honestly. The same definition powers `citationsTrend[].citationRate` so trend deltas track real movement, not provider-mix variance.
165
+ - `citationsTrend` excludes partial runs to avoid skew. A project with only one completed run gets `trend: "unknown"` and the finding "No prior run to compare against." — not "Flat compared to the previous run."
166
+ - Project ownership uses subdomain-aware matching against `project.canonicalDomain` plus any configured `ownedDomains`. `blog.example.com` and `brand.io` count as the project, not as external sources, when those rules apply.
167
+ - Competitor tagging in `aiSourceOrigin.topDomains` uses the same subdomain-aware match — `blog.rival.com` is `isCompetitor: true` when `rival.com` is tracked.
168
+ - AI referral totals dedupe overlapping GA4 attribution dimensions (`session` / `first_user` / `manual_utm`) by picking the largest dimension per `(date, source, medium)`. Two 10-session rows for the same tuple report 10 sessions, not 20.
169
+ - GSC top-query CTR and avgPosition are impression-weighted, matching GSC's own metric semantics across multi-row queries.
170
+
171
+ ## Analytics
172
+
173
+ ```bash
174
+ cnry analytics <project> # default analytics view
175
+ cnry analytics <project> --feature metrics # mention + citation rate trends (BrandMetricsDto: mentionTrend primary, trend secondary)
176
+ cnry analytics <project> --feature gaps # brand gap analysis — mention buckets (mentionedQueries[]/mentionGap[]/notMentioned[]) primary, cited buckets (cited[]/gap[]/uncited[]) secondary
177
+ cnry analytics <project> --feature sources # source breakdown by category
178
+ cnry analytics <project> --window 7d # time window: 7d, 30d, 90d, all
179
+ ```
180
+
181
+ ### Cited-source rankings (`cnry sources`)
182
+
183
+ "Where do AI engines get the facts they cite?" — the full, per-provider, classified ranking of cited domains. Backed by `GET /api/v1/projects/<name>/analytics/sources` and the `canonry_analytics_sources` MCP tool. All counts/shares/classification are computed server-side; the CLI only renders.
184
+
185
+ ```bash
186
+ cnry sources <project> # surface-class roll-up (own / direct-competitor / ota-aggregator / editorial-media / other)
187
+ cnry sources <project> --rank # full ranked cited-domain list, each tagged with category + surface class
188
+ cnry sources <project> --rank --limit 20 # top 20 domains; an explicit long-tail rollup preserves the totals
189
+ cnry sources <project> --by-provider # per-provider cited-domain mix + each provider's total cited slots
190
+ cnry sources <project> --window 30d --format json # window-filterable; --format json emits the SourceBreakdownDto directly
191
+ cnry sources <project> --rank --format jsonl # stream the ranked domains, one self-contained record per line
192
+ ```
193
+
194
+ - **Surface class** is deterministic (no LLM): `own` = the project's `canonicalDomain`/`ownedDomains`; `direct-competitor` = a tracked competitor; `ota-aggregator` = directories/marketplaces (Yelp, Booking.com, Tripadvisor, Amazon…); `editorial-media` = news/blogs/reference; `other` = everything else. When discovery has run, its stored per-domain classifications (`domain_classifications`) enrich recall for niche OTAs/regional media the static allow-list misses — `own` and tracked competitors always stay authoritative. Running `cnry discover run` improves coverage.
195
+ - The ranked list is **not truncated** by default (the old top-5-per-category cap is gone). Pass `--limit N` to cap each list; the response carries `truncatedDomainCount` / `truncatedCitedSlots` so totals always reconcile.
196
+ - Counts are **cited slots** (grounding citations), so a domain cited 3× in one answer counts 3. Probe runs are excluded.
197
+
198
+ ### Aggregated visibility stats (`cnry visibility-stats`)
199
+
200
+ Per-query mention (answer-text) and citation (source-list) **counts with a sample size**, pooled across many answer-visibility runs — the data to compute a confidence-aware proportion (e.g. Wilson) or detect drift without fetching every run. Backed by `GET /api/v1/projects/<name>/visibility-stats` and the `canonry_visibility_stats` MCP tool. Probe runs and non-`answer-visibility` runs are excluded; only completed/partial runs count. `--share-of-voice` adds a pooled head-to-head vs tracked competitors (`project / (project + competitor)` brand mentions) — the primitive for a month-over-month client report: call it with `--month <this>` and `--month <last>` and compare.
201
+
202
+ ```bash
203
+ cnry visibility-stats <project> # all runs; per-query cited/total + mentioned/checked + pooled TOTAL
204
+ cnry visibility-stats <project> --last-runs 10 # most recent 10 runs (mutually exclusive with --since/--until/--month)
205
+ cnry visibility-stats <project> --since 2026-06-01 --until 2026-06-30 # ISO date/time window on run createdAt
206
+ cnry visibility-stats <project> --month 2026-06 # a whole calendar month (YYYY-MM), expanded to that month's UTC bounds
207
+ cnry visibility-stats <project> --by-provider # per-provider breakdown (counts sum to the pooled counts)
208
+ cnry visibility-stats <project> --month 2026-06 --share-of-voice # pooled share of voice vs competitors for the month (the m/m report primitive)
209
+ cnry visibility-stats <project> --format jsonl # stream one record per query, stamped with project + runCount
210
+ ```
211
+
212
+ - **Tri-state aware:** `checked` counts only snapshots where `answerMentioned` was recorded — `null` ("not checked") is **excluded**, never counted as not-mentioned. So `checked` is the correct `n` for a mention proportion. `mentionRate = mentioned/checked`; `citedRate = cited/total` (citation_state is always populated, so the citation `n` is `total`). Both rates are `null` when their denominator is 0 (undefined over no samples).
213
+ - **Date-only window:** `--since`/`--until` accept a full ISO instant or a bare `YYYY-MM-DD`. A date-only `--until 2026-06-30` covers the **whole** UTC day (through 23:59:59.999), so same-day runs are included; a date-only `--since` is that day's start.
214
+ - **Unbounded by default:** with no `--since`/`--until`/`--last-runs`, every completed/partial run is pooled (`window.runCount` reports how many). For a recent sample, bound it with `--last-runs N`.
215
+ - **`groupBy` in the payload:** present (`"provider"`) only with `--by-provider`; omitted otherwise (absent = no breakdown) — the generated SDK types it `groupBy?: 'provider'`.
216
+ - **mention vs cited stay independent** — a model can do either, both, or neither. Don't read one from the other.
217
+
218
+ ## Technical AEO (site audit)
219
+
220
+ Site-wide technical audit (structured data, AI-readable content, AI-crawler access, content depth/freshness/extractability, …) powered by `@ainyc/aeo-audit`'s `runSitemapAudit`. Runs as the `site-audit` run kind — crawls the project's `sitemap.xml` and scores every reachable page, then rolls up into one 0–100 site score. Pure HTTP, no LLM cost; a large site can take minutes, so it runs in the background.
221
+
222
+ ```bash
223
+ cnry technical-aeo run <project> --wait # crawl + audit; --wait polls to terminal. Idempotent: returns the in-flight run if one is active.
224
+ cnry technical-aeo run <project> --sitemap-url <url> --limit 200 # override the sitemap / cap pages (highest <priority> first; default 500, max 2000)
225
+ cnry technical-aeo score <project> [--format json] # site score + per-factor scorecard (avg + pass/partial/fail per page) + delta vs the previous audit
226
+ cnry technical-aeo pages <project> [--status error] [--sort score-asc|score-desc|url] [--format json|jsonl] # per-page breakdown of the latest run (worst-first by default)
227
+ cnry technical-aeo trend <project> [--format json|jsonl] # aggregate-score history across past audits
228
+ cnry schedule set <project> --kind site-audit --preset weekly # keep it fresh
229
+ ```
230
+
231
+ - The score is only available after at least one audit runs — `score` returns `hasData: false` until then.
232
+ - Reads reflect the latest **completed/partial** site-audit run; probe runs are excluded.
233
+
234
+ ## Intelligence
235
+
236
+ ```bash
237
+ cnry insights <project> # list active insights (regressions, gains, opportunities)
238
+ cnry insights <project> --type gbp-* # filter by insight type; trailing * = prefix (e.g. only GBP insights)
239
+ cnry insights <project> --type gbp-description-missing # exact type match
240
+ cnry insights <project> --severity high # minimum severity (high returns high + critical)
241
+ cnry insights <project> --limit 10 # cap to the newest N
242
+ cnry insights <project> --dismissed # include dismissed insights
243
+ cnry insights <project> --format json # JSON output
244
+ cnry insights dismiss <project> <id> # dismiss an insight
245
+ cnry health <project> # latest citation health snapshot (citation-only — see known gap below)
246
+ cnry health <project> --history # health trend over time
247
+ cnry health <project> --history --limit 10 # limit history entries
248
+ cnry health <project> --format json # JSON output
249
+ cnry backfill insights <project> # backfill insights for all completed runs
250
+ cnry backfill insights <project> --from-run <id> --to-run <id> # backfill a range
251
+ ```
252
+
253
+ > **Known gap (mention-first read):** `cnry health` is **citation-only** today — it has no mention dimension. For the primary mention-first read, use `cnry overview` and `cnry get <project> scores.mentionCoverage.value` / `cnry get <project> scores.mentionShare.value` until health is extended.
254
+
255
+ ## Queries & Competitors
256
+
257
+ ```bash
258
+ cnry query add <project> "phrase one" "phrase two"
259
+ cnry query replace <project> "phrase one" "phrase two" # set the basket to exactly this list
260
+ cnry query replace <project> "..." --dry-run # preview adds/removes via /queries/replace-preview
261
+ cnry query remove <project> "phrase"
262
+ cnry query list <project>
263
+ cnry query import <project> queries.txt
264
+ cnry query generate <project> --provider gemini --count 10 --save
265
+
266
+ cnry competitor add <project> competitor1.com competitor2.com
267
+ cnry competitor list <project>
268
+ ```
269
+
270
+ ## Scheduling & Notifications
271
+
272
+ ```bash
273
+ cnry schedule set <project> --preset daily # or: weekly, twice-daily, daily@09
274
+ cnry schedule set <project> --cron "0 9 * * *" --timezone America/New_York
275
+ cnry schedule set <project> --kind data-refresh --preset daily # refresh all connected GSC/Bing/GA/GBP integrations (no --source)
276
+ cnry schedule set <project> --kind backlinks-sync --preset weekly # re-probe Common Crawl; sync only when a newer rolling window is published (no --source/--provider)
277
+ cnry schedule set <project> --kind site-audit --preset weekly # Technical AEO: crawl the sitemap + audit every page (no --source/--provider)
278
+ cnry schedule show <project>
279
+ cnry schedule enable <project>
280
+ cnry schedule disable <project>
281
+ cnry schedule remove <project>
282
+
283
+ cnry notify add <project> --webhook <url> --events citation.lost,citation.gained
284
+ cnry notify events # list all available event types
285
+ cnry notify list <project>
286
+ cnry notify remove <project> <id>
287
+ cnry notify test <project> <id>
288
+ ```
289
+
290
+ Available events: `citation.lost`, `citation.gained`, `run.completed`, `run.failed`, `insight.critical`, `insight.high`
291
+
292
+ `insight.critical` and `insight.high` fire when the intelligence engine generates critical- or high-severity insights after a sweep completes.
293
+
294
+ > **No mention events yet.** Notification events cover the citation signal only — there are **no** `mention.lost` / `mention.gained` events today. For mention-first monitoring, read `scores.mentionCoverage` / `scores.mentionShare` via `cnry overview` (or the `insight.*` events, which can be driven by mention-side insights); do not wire automation to mention events that aren't emitted.
295
+
296
+ ## Provider Settings & Quotas
297
+
298
+ ```bash
299
+ cnry settings # show config: providers, apiUrl, db path
300
+ cnry settings --format json
301
+ cnry settings provider gemini --api-key <KEY> --model gemini-2.5-flash
302
+ cnry settings provider openai --max-per-day 1000 --max-per-minute 20
303
+ cnry settings provider perplexity --api-key <KEY>
304
+ ```
305
+
306
+ Quota flags: `--max-concurrent`, `--max-per-minute`, `--max-per-day`
307
+
308
+ Available providers: `gemini`, `openai`, `claude`, `perplexity`, `local`, `cdp`
309
+
310
+ If a provider hits rate limits (429 errors), the run completes as `partial`. Reduce concurrency or increase time between sweeps.
311
+
312
+ ### Gemini Vertex AI
313
+
314
+ Gemini supports Vertex AI as an alternative to API key authentication. Use GCP Application Default Credentials (ADC) or a service account JSON key file:
315
+
316
+ ```bash
317
+ # Via env vars (recommended for servers)
318
+ export GEMINI_VERTEX_PROJECT=my-gcp-project
319
+ export GEMINI_VERTEX_REGION=us-central1 # optional, defaults to us-central1
320
+ export GEMINI_VERTEX_CREDENTIALS=/path/to/sa.json # optional, falls back to ADC
321
+
322
+ # Or in canonry.yaml config
323
+ # vertexProject, vertexRegion, vertexCredentials fields under provider config
324
+ ```
325
+
326
+ When Vertex AI is configured, no `GEMINI_API_KEY` is required. The provider uses the `@google-cloud/vertexai` SDK with `googleAuthOptions` for credential handling.
327
+
328
+ ## API Keys
329
+
330
+ Mint, list, and revoke the `cnry_…` bearer tokens stored in the `api_keys` table. Keys are stored as a sha256 hash, never in plaintext.
331
+
332
+ ```bash
333
+ cnry key list # table: NAME / PREFIX / SCOPES / CREATED / LAST USED / STATUS
334
+ cnry key list --format json|jsonl # jsonl streams one key per line
335
+ cnry key create --name ci-bot # mint a full-access key (scopes default to *)
336
+ cnry key create --name reader --read-only # read-only key: scopes=['read'], denied every write HTTP method
337
+ cnry key create --name reader --scope read # narrower key; repeat --scope or comma-separate (--scope a,b)
338
+ cnry key create --name ci-bot --format json # JSON output includes the plaintext key
339
+ cnry key revoke <id> # revoke (does not delete); effective on the next request
340
+ cnry key whoami [--format json] # introspect the CURRENT key (name, scopes, readOnly, status)
341
+ ```
342
+
343
+ - **`--read-only` keys can read everything but write nothing.** The server denies every mutating method (POST/PUT/PATCH/DELETE) for a read-only key (`403 FORBIDDEN`); GET/HEAD pass. `--read-only` is sugar for `--scope read` and cannot be combined with `--scope`. The key DTO carries a derived `readOnly: boolean`. Point `canonry-mcp` at a read-only key and it auto-restricts to read tools.
344
+
345
+ - **Create returns the plaintext key exactly once.** It is shown with a "Save this now — it will not be shown again." warning (and is included in the JSON under `key`). It cannot be recovered later, so persist it on receipt.
346
+ - **List never exposes the hash or plaintext** — only safe metadata (id, name, prefix, scopes, created / last-used / revoked timestamps).
347
+ - **Mutations are gated by the `keys.write` scope.** The default key from `cnry init` carries `*`, which satisfies it. A narrower key needs `keys.write` to mint or revoke.
348
+ - **Revoke is not delete.** It sets `revokedAt`; the auth layer rejects the key on the next request. Revoking an already-revoked key is a no-op. You cannot revoke the key you are currently authenticating with (use a different key).
349
+
350
+ ## Google Search Console
351
+
352
+ ```bash
353
+ cnry google connect <project> # initiate OAuth flow
354
+ cnry google disconnect <project> # disconnect GSC
355
+ cnry google status <project> # connection status
356
+ cnry google properties <project> # list available properties
357
+ cnry google set-property <project> <url> # set GSC property URL
358
+ cnry google set-sitemap <project> <url> # set sitemap URL
359
+ cnry google list-sitemaps <project> # list submitted sitemaps
360
+ cnry google discover-sitemaps <project> --wait # auto-discover and inspect
361
+
362
+ cnry google sync <project> # sync GSC data
363
+ cnry google sync <project> --days 30 --full --wait # full sync with wait
364
+
365
+ cnry google coverage <project> # index coverage summary
366
+ cnry google refresh <project> # force-fetch fresh GSC coverage data
367
+ cnry google performance <project> # search performance data
368
+ cnry google performance <project> --days 30 --keyword "term" --page "/url"
369
+
370
+ cnry google inspect <project> <url> # inspect specific URL
371
+ cnry google inspect-sitemap <project> --wait # bulk inspect all sitemap URLs
372
+ cnry google inspections <project> # inspection history
373
+ cnry google inspections <project> --url <url> # filter by URL
374
+ cnry google deindexed <project> # pages that lost indexing
375
+
376
+ cnry google request-indexing <project> <url> # push URL to Google
377
+ cnry google request-indexing <project> --all-unindexed # push all unknown pages
378
+ ```
379
+
380
+ ## Discovery (Tracked-Basket Expansion)
381
+
382
+ ```bash
383
+ cnry discover run <project> --icp "..." --wait --format json # full pipeline: seed → embed → cluster → probe → bucket
384
+ cnry discover run <project> --icp "..." --dedup-threshold 0.95 # tune cosine threshold (default 0.95)
385
+ cnry discover run <project> --icp "..." --max-probes 100 # per-session probe budget (default 100, hard cap 500)
386
+ cnry discover run <project> --icp-angle "angle 1" --icp-angle "angle 2" --wait # multi-angle: one session per ICP angle, useful for hyperlocal/niche businesses
387
+ cnry discover run <project> --icp "..." --locations michigan,florida # geo-constrain seed generation to a subset of project locations (omit = all project locations)
388
+
389
+ cnry discover list <project> # newest-first session list
390
+ cnry discover show <project> <session-id> # per-query probe rows + buckets + classified competitor domains
391
+ cnry discover harvest <project> <session-id> # gated candidate seeds from the model's issued search-query fan-out (read-only; nothing is probed/tracked/promoted)
392
+ cnry discover harvest <project> <session-id> --min-probe-hits 2 --no-anchor # raise the recurrence floor / disable the subject anchor
393
+ cnry discover promote preview <project> <session-id> # preview bucketed candidates + recurring suggested competitors of every classified type (read-only)
394
+ cnry discover promote <project> <session-id> # adopt cited + aspirational queries + direct-competitor domains
395
+ cnry discover promote <project> <session-id> --competitor-types direct-competitor,editorial-media # widen the competitor merge to other classified types
396
+ cnry discover promote <project> <session-id> --bucket aspirational --no-competitors # scope to a bucket subset / skip competitor merge
397
+ ```
398
+
399
+ Discovery requires Gemini configured (API key today; Vertex-mode embeddings are deferred). The pipeline writes a `discovery_sessions` row, a `runs` row (kind `aeo-discover-probe`), and one `discovery.basket-divergence` insight when the session completes. `discover harvest` is a separate read-only view over a completed session: it reads the search queries the answer engine actually issued to answer each probe (Gemini's `groundingMetadata.webSearchQueries` fan-out, already stored in the probe's `raw_response`) back out — no new model call — runs a mandatory quality gate (drops navigational/phone lookups, over-specific outliers, off-subject acronym collisions, exact already-tracked matches, and — via an embedding cosine pass over your tracked queries — semantic duplicates like paraphrases/synonyms that exact match can't see) and returns the survivors ranked by how many distinct probes issued each one. The output's `semanticNoveltyApplied` reports whether the embedding pass ran (it falls back to exact-match when no Gemini key is configured). These are a third signal — *issued retrieval queries*, neither "mention" nor "cited" — and carry no demand of their own; they are candidate seeds, so review them and add the good ones with `cnry query add`. Use `--min-probe-hits` to require recurrence and `--no-anchor` to allow new-subject discovery on a well-scoped project. Seed generation is location-aware: a project with locations configured (or a `--locations` label subset) geo-constrains the seed prompt so generated queries stay inside the service area, and a multi-location project gets a per-area seed quota so one area cannot dominate — `--locations` labels must match the project's configured locations or the run is rejected; projects with no locations are unaffected. After probing, one Gemini call classifies every recurring cited domain as `direct-competitor`, `ota-aggregator`, `editorial-media`, or `other` (a failed/legacy classification leaves domains `unknown`). Aero wakes unprompted with the bucket-count payload so the operator can act without polling. `discover promote` defaults to cited + aspirational queries and `direct-competitor` domains only — aggregators and editorial media are suppressed; pass `--competitor-types` to widen the merge (or to recover legacy `unknown` entries) and `--bucket wasted-surface` for off-ICP competitor gaps. Promotion is add-only and idempotent — queries/domains already tracked are reported as skipped, never inserted twice — and only works on `completed` sessions; promoted rows carry `provenance="discovery:<sessionId>"`.
400
+
401
+ ## Bing Webmaster Tools
402
+
403
+ ```bash
404
+ cnry bing connect <project> --api-key <key> # connect Bing WMT
405
+ cnry bing disconnect <project> # disconnect
406
+ cnry bing status <project> # connection status
407
+ cnry bing sites <project> # list verified sites
408
+ cnry bing set-site <project> <url> # set active site URL
409
+ cnry bing coverage <project> # URL coverage data
410
+ cnry bing refresh <project> # force-fetch fresh Bing coverage data
411
+ cnry bing inspect <project> <url> # inspect specific URL
412
+ cnry bing inspect-sitemap <project> # discover sitemap URLs and inspect each via Bing
413
+ cnry bing inspect-sitemap <project> --sitemap-url <url> --wait # explicit sitemap, wait for run
414
+ cnry bing inspections <project> # inspection history
415
+ cnry bing request-indexing <project> <url> # submit URL for indexing
416
+ cnry bing request-indexing <project> --all-unindexed # submit all unindexed
417
+ cnry bing performance <project> # search performance data
418
+ ```
419
+
420
+ ## WordPress Integration
421
+
422
+ ```bash
423
+ cnry wordpress connect <project> --url <url> --user <user> # connect (prompts for app password)
424
+ cnry wordpress disconnect <project> # disconnect
425
+ cnry wordpress status <project> # connection status
426
+ cnry wordpress pages <project> [--live|--staging] # list pages
427
+ cnry wordpress page <project> <slug> # show page detail
428
+ cnry wordpress create-page <project> --title <t> --slug <s> --content <c> # create page
429
+ cnry wordpress update-page <project> <slug> --content <c> # update page
430
+ cnry wordpress set-meta <project> <slug> --title <t> # set SEO meta (single page)
431
+ cnry wordpress set-meta <project> --from <file> # bulk set SEO meta from JSON
432
+ cnry wordpress schema <project> <slug> # read page JSON-LD
433
+ cnry wordpress schema deploy <project> --profile <file> # deploy schema from profile
434
+ cnry wordpress schema status <project> # schema status per page
435
+ cnry wordpress set-schema <project> <slug> # manual schema handoff
436
+ cnry wordpress audit <project> # audit pages for SEO issues
437
+ cnry wordpress diff <project> <slug> # compare live vs staging
438
+ cnry wordpress staging status <project> # staging config status
439
+ cnry wordpress staging push <project> # manual staging push handoff
440
+ cnry wordpress llms-txt <project> # read /llms.txt
441
+ cnry wordpress set-llms-txt <project> # manual llms.txt handoff
442
+ cnry wordpress onboard <project> --url <url> --user <user> # full onboarding workflow
443
+ ```
444
+
445
+ **Onboard** runs: connect → audit → set-meta → schema deploy → Google submit → Bing submit. Use `--skip-schema` or `--skip-submit` to skip steps. `--profile <file>` provides business data and page-to-schema mapping for schema deployment.
446
+
447
+ ## Google Analytics 4
448
+
449
+ GA4 integration uses service account authentication (no OAuth). The service account must have Viewer access on the GA4 property. `ga sync` writes to four DB tables (`gaTrafficSnapshots`, `gaAiReferrals`, `gaSocialReferrals`, `gaTrafficSummaries`); every subsequent read command queries the local store rather than re-fetching from GA4, so reads are fast and quotaless. AI-referral rows are tracked across 10 known providers (chatgpt, perplexity, claude, gemini, openai, anthropic, copilot, phind, you.com, meta.ai), three GA4 attribution dimensions (`session` / `first_user` / `manual_utm`), and joined to landing pages. Social referrals are split Organic vs Paid via GA4's `sessionDefaultChannelGroup`. All commands support `--format json`.
450
+
451
+ ```bash
452
+ cnry ga connect <project> --property-id <id> --key-file ./sa-key.json
453
+ # connect via service account (auth method = service_account)
454
+ cnry ga disconnect <project> # disconnect; deletes all synced rows for the project
455
+ cnry ga status <project> # connected, propertyId, authMethod, lastSyncedAt
456
+ cnry ga sync <project> [--days 30] [--only traffic|ai|social]
457
+ # refresh from GA4 → DB; --only restricts which slice is replaced
458
+ # returns: synced, rowCount, aiReferralCount, socialReferralCount,
459
+ # syncedComponents, syncedAt
460
+ cnry ga traffic <project> # current-period rollup; returns: totalSessions,
461
+ # totalOrganicSessions/totalDirectSessions/totalUsers,
462
+ # organicSharePct/aiSharePct/socialSharePct/directSharePct,
463
+ # topPages[], aiReferrals[], aiReferralLandingPages[],
464
+ # aiSessionsDeduped, aiUsersBySession, socialReferrals[]
465
+ cnry ga attribution <project> [--trend] # unified channel breakdown (organic / ai / social / direct
466
+ # sessions + raw and display share %s); --trend adds 7d/30d
467
+ # direction per channel + biggest mover
468
+ cnry ga ai-referral-history <project> # daily array of {date, source, medium, attribution,
469
+ # sessions, users}; one row per (day × source × dimension)
470
+ cnry ga social-referral-history <project> # daily array of {date, source, medium, channel,
471
+ # sessions, users}; channel ∈ {Organic Social, Paid Social}
472
+ cnry ga social-referral-summary <project> [--trend]
473
+ # one-line social rollup: socialSessions, socialUsers,
474
+ # socialSharePct, topSources[]; --trend adds 7d/30d direction
475
+ cnry ga session-history <project> # daily totals: {date, sessions, organicSessions, users}
476
+ cnry ga coverage <project> # per-page overlay: {landingPage, sessions,
477
+ # organicSessions, users}
478
+ ```
479
+
480
+ Every read command queries persisted DB rows, so a stale `lastSyncedAt` means the response is stale — always check `ga status` before drawing conclusions, and re-`ga sync` if the data is older than the analysis window. Use `--only ai` or `--only social` to refresh just one slice when iterating.
481
+
482
+ ## Google Business Profile (Local AEO)
483
+
484
+ GBP integration tracks how AI engines see a business's local presence — search-keyword impressions, daily performance metrics, hotel lodging attributes, and booking CTAs. It reuses the **Google OAuth client** (same `google.clientId`/`clientSecret` as GSC; the connection is stored under the `gbp` connection type). **Hard prerequisite:** the Google Cloud project must be approved through Google's Business Profile API Basic Access form, or every call returns HTTP 403 at 0 QPM. See `references/google-business-profile.md` for the full GCP-setup + access-request playbook, the reviews/Q&A gating, and real-world data-shape quirks.
485
+
486
+ Like GA4, `gbp sync` writes to local DB tables and every read command queries the local store — reads are fast and quotaless; a stale sync means stale reads. All commands support `--format json`.
487
+
488
+ ```bash
489
+ cnry gbp connect <project> [--public-url <url>] # OAuth connect (reuses the Google client)
490
+ cnry gbp disconnect <project> # remove the GBP connection + ALL synced GBP data
491
+ cnry gbp accounts <project> # list GBP accounts this connection can access
492
+ # (account selection is per project — pick one below)
493
+ cnry gbp locations discover <project> [--account accounts/{n}] [--switch-account] [--no-select-new]
494
+ # discover a chosen account's locations; --account targets a
495
+ # specific account (omit = the account the project already tracks,
496
+ # else the first visible one); --switch-account opts into the
497
+ # destructive re-point to a different account; selects all new by default
498
+ cnry gbp locations <project> [--selected-only] # list discovered locations + selection state
499
+ cnry gbp locations select <project> --location locations/{n}
500
+ cnry gbp locations deselect <project> --location locations/{n}
501
+ # only SELECTED locations are synced
502
+ cnry gbp sync <project> [--location locations/{n}] [--days N] [--months N] [--wait]
503
+ # fires the gbp-sync run: daily metrics + keyword impressions
504
+ # + place-action links + lodging snapshot per selected location;
505
+ # --wait polls to a terminal run status
506
+ cnry gbp metrics <project> [--location locations/{n}] [--metric <DailyMetric>]
507
+ # stored daily metrics + totals-by-metric
508
+ cnry gbp keywords <project> [--location locations/{n}]
509
+ # stored search-keyword impressions over the synced
510
+ # periodStart..periodEnd window; renders exact counts and
511
+ # <N thresholded floors + a thresholdedPct fidelity stat
512
+ cnry gbp place-actions <project> [--location locations/{n}]
513
+ # booking / reservation / order CTAs per location, with
514
+ # placeActionType, providerType (MERCHANT vs AGGREGATOR), isPreferred, uri
515
+ cnry gbp lodging <project> [--location locations/{n}]
516
+ # latest hotel-attribute snapshot per location (snapshot-on-change):
517
+ # populatedGroupCount + syncedAt; populatedGroupCount=0 means the Lodging
518
+ # API returns no structured attributes (common even for complete hotels;
519
+ # verify the "Hotel details" panel), a verify signal, not a confirmed gap
520
+ cnry gbp places <project> [--location locations/{n}]
521
+ # latest Places-API rendered-listing snapshot per location: the
522
+ # server-derived `amenities` the public listing advertises (#648 cross-reference).
523
+ # Needs a Places API key (places.apiKey / GOOGLE_PLACES_API_KEY)
524
+ cnry gbp summary <project> [--location locations/{n}]
525
+ # composite scorecard: performance totals + recent-vs-prior 7d
526
+ # deltas (deltaPct null when prior=0), keyword coverage,
527
+ # place-action CTA presence flags, Lodging API readable-group counts,
528
+ # owner-content profile completeness (secondary categories /
529
+ # description / service area / hours / phone + closed-status counts)
530
+ ```
531
+
532
+ `gbp sync` produces a run with the standard statuses (`completed` / `partial` / `failed`); `partial` means some selected locations synced and others errored (the per-location errors are on the run). Non-lodging locations are skipped cleanly (Google answers the lodging call with HTTP 400, not 404). Reviews are **not** synced — the v4 Reviews API is producer-restricted by Google and unavailable on most projects; the Q&A API was retired (2025-11-03).
533
+
534
+ ## OpenAI ads (ChatGPT ads)
535
+
536
+ Paid-surface data for the project's connected OpenAI ad account. Ads render only in the ChatGPT consumer UI (never in API answers), so the Advertiser API is the only window into the paid layer. Money is integer micros in all stored/JSON data; insights `ctr`/`cpcMicros` are derived server-side and `null` on zero denominators. Paid metrics are "paid"/"sponsored" — never conflate with organic `cited`/`mentioned`.
537
+
538
+ ```
539
+ cnry ads connect <project> --api-key <sdk-key> # mint the key in OpenAI Ads Manager; validated upstream, stored in ~/.canonry/config.yaml
540
+ cnry ads status <project>
541
+ cnry ads sync <project> # ads-sync run: entity snapshots + daily rollups
542
+ cnry ads campaigns <project> --format jsonl # snapshots incl. context hints (newline-separated example queries)
543
+ cnry ads insights <project> --level campaign --from 2026-06-01 --format jsonl
544
+ cnry ads summary <project> # campaign-level totals only (no double counting)
545
+ cnry ads disconnect <project>
546
+ cnry schedule set <project> --kind ads-sync --preset daily
547
+ ```
548
+
549
+ `ads sync` runs report `completed` / `partial` (some campaigns failed; per-campaign errors on the run) / `failed`. Doctor checks: `ads.auth.connection`, `ads.data.recent-sync` (both skipped when not connected).
550
+
551
+
552
+ ## Backlinks (source-aware: Common Crawl + Bing Webmaster)
553
+
554
+ Backlinks are **source-aware** — every read takes a `--source commoncrawl|bing-webmaster` filter (default `commoncrawl`) and rows are tagged with their source:
555
+
556
+ - **Common Crawl** — free public hyperlink graph, ~monthly. Workspace-level release sync + per-project extract. Requires DuckDB (install once with `cnry backlinks install`). Releases download once per workspace and reuse across projects.
557
+ - **Bing Webmaster** — live first-party inbound links from a connected Bing account (`cnry bing connect <project> --api-key <key>`). Per-project; pull with `cnry backlinks bing-sync`.
558
+
559
+ Common Crawl publishes the hyperlink graph as **rolling, monthly-stepped, overlapping 3-month windows** named by the window's first month's year: `cc-main-YYYY-<mon>-<mon>-<mon>` (e.g. `cc-main-2026-mar-apr-may`). Omit `--release` to auto-discover the newest published window. Bing windows are synthetic per-UTC-day snapshots (`bing-YYYY-MM-DD`).
560
+
561
+ ```bash
562
+ cnry backlinks install # install bundled DuckDB binary (Common Crawl only)
563
+ cnry backlinks doctor # show install + plugin status
564
+ cnry backlinks status # latest workspace Common Crawl release sync
565
+ cnry backlinks releases # list cached releases on disk
566
+ cnry backlinks releases latest # probe Common Crawl for the newest published rolling window
567
+ cnry backlinks sync # Common Crawl: auto-discover + download + query the newest release (workspace-wide)
568
+ cnry backlinks sync --release cc-main-2026-mar-apr-may --wait # pin a window, block until ready/failed
569
+ cnry backlinks sources <project> # per-source availability (connected / has-data / latest window / freshness)
570
+ cnry backlinks sources <project> --exclude-crawlers # availability counts drop crawler/proxy hosts (matches the dashboard)
571
+ cnry backlinks bing-sync <project> # Bing: pull live inbound links for the project (needs Bing connected)
572
+ cnry backlinks bing-sync <project> --wait # block until the sync run completes
573
+ cnry backlinks list <project> # top linking domains (Common Crawl, default)
574
+ cnry backlinks list <project> --source bing-webmaster # top linking domains from Bing inbound links
575
+ cnry backlinks list <project> --limit 100 --release <id>
576
+ cnry backlinks extract <project> --release <id> --wait # Common Crawl: re-extract against a ready release
577
+ cnry backlinks cache prune --release <id> # delete cached release files from disk
578
+ ```
579
+
580
+ All commands support `--format json`; collection commands (`list`, `sources`, `releases`) also support `--format jsonl`. A Common Crawl release sync has statuses `queued` → `downloading` → `querying` → `ready` / `failed`. Per-project extract / Bing-sync runs use the standard run statuses (`queued` → `running` → `completed` / `failed`). Projects with `autoExtractBacklinks` enabled get a Common Crawl extract enqueued automatically when a release sync transitions to `ready`; Bing-connected projects with a `backlinks-sync` schedule get a Bing pull each tick.
581
+
582
+ **`jsonl` output schema:** `backlinks list` streams `rows` (each `{ project, release, targetDomain, linkingDomain, numHosts, source }`); `backlinks sources` streams the per-source availability list (`{ project, targetDomain, source, connected, hasData, latestRelease, totalLinkingDomains, lastSyncedAt }`); `backlinks releases` streams cached-release rows bare.
583
+
584
+ To keep backlinks fresh automatically, schedule a `backlinks-sync` kind (`cnry schedule set <project> --kind backlinks-sync --preset weekly`): each tick re-probes Common Crawl and runs the workspace release sync **only when a newer rolling window is published** (it skips when the newest `ready` sync already covers the latest release, so it never re-downloads a near-identical window).
585
+
586
+ ## CDP / Browser Provider
587
+
588
+ The CDP (Chrome DevTools Protocol) provider enables browser-based queries against AI chat interfaces (e.g., ChatGPT). This gives more accurate results than API-based providers for some use cases.
589
+
590
+ ```bash
591
+ cnry cdp connect --host localhost --port 9222 # connect to Chrome CDP
592
+ cnry cdp status # show connection status
593
+ cnry cdp targets # list available targets (ChatGPT, etc.)
594
+ cnry cdp screenshot <query> --targets chatgpt # screenshot a query result
595
+ ```
596
+
597
+ **Requires:** Chrome running with `--remote-debugging-port=9222`
598
+
599
+ ## Telemetry
600
+
601
+ ```bash
602
+ cnry telemetry status # show telemetry status
603
+ cnry telemetry enable # enable anonymous telemetry
604
+ cnry telemetry disable # disable telemetry
605
+ ```
606
+
607
+ ## Config as Code
608
+
609
+ ```bash
610
+ cnry apply project.yaml # apply declarative config
611
+ cnry apply file1.yaml file2.yaml # multiple files
612
+ cnry export <project> --include-results > project.yaml
613
+ cnry sitemap inspect <project>
614
+ ```
615
+
616
+ ## Agent
617
+
618
+ Canonry ships the built-in **Aero** agent (backed by pi-agent-core) for users
619
+ who don't already have one, plus a webhook integration for users who want to
620
+ drive Canonry from Claude Code / Codex / a custom agent.
621
+
622
+ ### Built-in Aero (one-shot CLI)
623
+
624
+ ```bash
625
+ # One-shot turn — Aero picks its own tools, streams events to stdout.
626
+ cnry agent ask <project> "<prompt>"
627
+ cnry agent ask <project> "<prompt>" --format json # JSON event stream
628
+ cnry agent ask --all "<prompt>" # fan out the same prompt across every project
629
+ cnry agent ask <project> "<prompt>" --trace # emit tool-execution detail for debugging
630
+
631
+ # Select a specific provider / model (otherwise auto-detected from config).
632
+ cnry agent ask <project> "<prompt>" --provider anthropic --model claude-opus-4-7
633
+ cnry agent ask <project> "<prompt>" --provider zai --model glm-5.1
634
+ cnry agent ask <project> "<prompt>" --provider openai
635
+ cnry agent ask <project> "<prompt>" --provider google
636
+ cnry agent ask <project> "<prompt>" --provider deepinfra --model zai-org/GLM-5.2 # Western-hosted GLM/DeepSeek (key: DEEPINFRA_TOKEN)
637
+
638
+ # Restrict the tool surface. Default is --scope all (full read+write surface).
639
+ # --scope read-only matches the dashboard bar default so pasted "Copy as CLI"
640
+ # commands can't enable writes the UI turn couldn't perform.
641
+ cnry agent ask <project> "<prompt>" --scope read-only
642
+ cnry agent ask <project> "<prompt>" --scope all
643
+
644
+ # Session + provider introspection
645
+ cnry agent providers <project> # list provider keys Aero will pick from + the resolved default
646
+ cnry agent transcript <project> # dump the rolling transcript for the current session
647
+ cnry agent reset <project> # start a fresh session (drops in-memory state, keeps memory)
648
+ cnry agent clear <project> # delete the transcript row from the DB
649
+
650
+ # Durable project notes (the <memory> hydrate block on every new session)
651
+ cnry agent memory list <project>
652
+ cnry agent memory set <project> --key <k> --value <v> # 2 KB cap per value
653
+ cnry agent memory forget <project> --key <k>
654
+ ```
655
+
656
+ **Provider detection order** when `--provider` is omitted: `anthropic` →
657
+ `openai` → `google` → `zai` → `deepinfra`, whichever has an API key present
658
+ first (from `~/.canonry/config.yaml` providers block, or the matching env var
659
+ `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` / `ZAI_API_KEY` /
660
+ `DEEPINFRA_TOKEN`). `deepinfra` (GLM-5.2) is a
661
+ Western-hosted OpenAI-compatible host — useful when the agent / analyze /
662
+ classify tiers must avoid PRC-hosted GLM (`zai`).
663
+
664
+ Conversations **persist per project** — `cnry agent ask` continues the
665
+ same rolling thread each invocation. Reset with `cnry agent reset <project>`
666
+ or via the dashboard bar's reset button.
667
+
668
+ ### External agents (webhook)
669
+
670
+ ```bash
671
+ # Wire an external agent webhook to a project
672
+ cnry agent attach <project> --url <webhook-url> # register webhook subscription
673
+ cnry agent attach <project> --url <url> --format json # JSON output
674
+ cnry agent detach <project> # remove the agent webhook
675
+ cnry agent detach <project> --format json # JSON output
676
+ ```
677
+
678
+ **Agent webhooks** fire on `run.completed`, `insight.critical`, `insight.high`, and `citation.gained`. The attach/detach pair is idempotent per project (one agent webhook per project, matched by source tag).
679
+
680
+ ## Output Formats
681
+
682
+ Every command takes `--format`:
683
+
684
+ - **`text`** (default) — human-readable, decorated. Not a stable parse target.
685
+ - **`json`** — one pretty-printed JSON document (the full envelope). Stable contract.
686
+ - **`jsonl`** — newline-delimited JSON: the command's **primary collection**, one self-contained record per line. The agent-friendly machine format — no envelope key to guess (`.checks` vs `.results` vs `.rows`), no `jq` flattening, greppable line by line.
687
+
688
+ `jsonl` is supported by every **collection** command — one whose primary output is a list: `insights`, `runs`, `evidence`, `history`, `query/keyword/competitor list`, `notify list/events`, `google` reads (`performance`, `performance-daily`, `inspections`, `coverage-history`, `deindexed`, `status`, `properties`, `list-sitemaps`), `bing` reads (`coverage-history`, `inspections`, `performance`, `sites`), `ga` reads (`ai-referral-history`, `social-referral-history`, `session-history`, `coverage`), `traffic events/sources/status`, `discover list/show`, `content targets/sources/gaps/map`, `backlinks list/releases`, `project list/locations`, `key list`, `agent memory list`, `agent providers`, `sources` (streams the ranked cited-domain list), and `doctor`. (`content brief` is an object command — `jsonl` degrades to its JSON document.)
689
+
690
+ Each `jsonl` line re-injects the envelope context it would otherwise lose, so a line lifted out still self-describes:
691
+
692
+ - project-scoped lists stamp `{ "project": "<name>", …row }`;
693
+ - `ga *-history` also stamps `window`; `traffic events` stamps `windowStart`/`windowEnd`; `backlinks list` stamps `release`/`targetDomain`; `discover show` stamps `sessionId`; `content targets` stamps `latestRunId`; `project locations` stamps `isDefault`;
694
+ - global lists whose rows already self-identify (`project list`, `notify events`, `backlinks releases`) emit bare rows.
695
+
696
+ Empty collection → **no output** (the exit code still conveys success, so "no records" stays distinct from "failure"). On failure a command prints its records (if any), then exits non-zero — branch on the **exit code**, never on parsing stderr. JSON field names and the `{ "error": { "code", "message" } }` envelope are a public contract.
697
+
698
+ **Composite** commands return a single aggregate object (not a list), so there is nothing to stream — on them `--format jsonl` **degrades to the same JSON document** as `--format json`; it never falls through to decorated human text. So `--format jsonl` is safe to pass to *any* command: collection commands stream their records, every other command emits its JSON document. Composite shapes are below.
699
+
700
+ ## Output schema per command
701
+
702
+ Compact reference for the composite / keyed commands agents read most (shapes can drift — the linked DTO source file is the source of truth; collection commands simply emit their primary array, see each command's own section above).
703
+
704
+ | Command | JSON output shape (top-level keys → DTO) | `jsonl` |
705
+ |---|---|---|
706
+ | `cnry doctor [--project p] [--all]` | `{ scope, project, generatedAt, durationMs, summary{total,ok,warn,fail,skipped}, checks[] }` — `DoctorReportDto` @ `contracts/doctor.ts`. `checks[]` = `CheckResultDto{ id, category, scope, title, status(ok\|warn\|fail\|skipped), code, summary, remediation?, details?, durationMs }`. With `--all`: an object keyed by `__global__` + each project name, each value a full report. | ✅ one check / line as `{project, …check}`; still exits non-zero if any `fail` |
707
+ | `cnry analytics <p> [--feature metrics\|gaps\|sources] [--window 7d\|30d\|90d\|all]` | Object **keyed by feature**: `{ metrics?, gaps?, sources? }` (all three present with no `--feature`; one with `--feature X`). `metrics`=`BrandMetricsDto{ window, buckets[], overall, byProvider, trend, mentionTrend, queryChanges[] }`; `gaps`=`GapAnalysisDto{ cited[], gap[], uncited[], mentionedQueries[], mentionGap[], notMentioned[], runId, window }` (each `[]`=`GapQuery`); `sources`=`SourceBreakdownDto` (same shape as `cnry sources`, below). @ `contracts/analytics.ts` | → degrades to the `json` document |
708
+ | `cnry sources <p> [--rank] [--limit N] [--by-provider] [--window …]` | `SourceBreakdownDto{ overall[], byQuery, ranked, byProvider, runId, window, limit }` @ `contracts/analytics.ts`. `ranked`/each `byProvider[name]` = `RankedSourceList{ totalCitedSlots, domainTotal, entries[], truncatedDomainCount, truncatedCitedSlots, bySurfaceClass[] }`; `entries[]`=`SourceRankEntry{ domain, count, percentage, category, label, surfaceClass }`; `bySurfaceClass[]`=`SurfaceClassCount{ surfaceClass, label, count, percentage, domainCount }`. `surfaceClass` ∈ own \| direct-competitor \| ota-aggregator \| editorial-media \| other. | ✅ streams `ranked.entries` one / line as `{project, …entry}` |
709
+ | `cnry visibility-stats <p> [--since <iso>] [--until <iso>] [--month <YYYY-MM>] [--last-runs N] [--by-provider] [--share-of-voice]` | `VisibilityStatsDto{ project, groupBy, window{since,until,lastRuns,runCount}, totals, byProvider?[], queries[], shareOfVoice? }` @ `contracts/visibility-stats.ts`. Each query / provider / totals entry = `{ total, checked, mentioned, cited, mentionRate, citedRate }` (+ `query`/`queryId`/`firstObserved`/`lastObserved` on queries, + `provider`/observed on provider entries). `checked`=snapshots with non-null `answerMentioned` (tri-state n for mention); `mentionRate=mentioned/checked`, `citedRate=cited/total`, both `null` on a 0 denominator. `byProvider`/per-query `providers` present only with `--by-provider`; counts sum to pooled. `--month YYYY-MM` echoes the resolved `window.since`/`until`. `shareOfVoice` present only with `--share-of-voice` = `{ percent, projectMentions, competitorMentions, snapshotsWithAnswerText, perCompetitor[{domain,mentions}] }`; `percent` (0-100) = `projectMentions/(projectMentions+competitorMentions)`, `null` when no competitors configured. | ✅ streams `queries` one / line as `{project, runCount, …query}` (envelope-only `shareOfVoice` not in the jsonl rows) |
710
+ | `cnry google coverage <p>` (index coverage) | `{ summary{total,indexed,notIndexed,deindexed,percentage}, lastInspectedAt, lastSyncedAt, indexed[], notIndexed[], deindexed[], reasonGroups[] }` — `GscCoverageSummaryDto` @ `contracts/google.ts`. `indexed[]`/`notIndexed[]`=`GscUrlInspectionDto`, `deindexed[]`=`GscDeindexedRowDto`. | → degrades to the `json` document. The single-array reads `google inspections` / `coverage-history` / `deindexed` **stream** `jsonl`. |
711
+ | `cnry ga traffic <p> [--window …]` | Object summary — `GA4TrafficSummaryDto` / `GaTrafficResponse` @ `contracts/ga.ts`: `{ totalSessions, totalOrganicSessions, totalDirectSessions, totalUsers, aiSessionsDeduped, aiUsersDeduped, aiSessionsBySession, aiUsersBySession, socialSessions, socialUsers, channelBreakdown{organic,social,direct,ai,other→{sessions,sharePct,sharePctDisplay}}, *SharePct (+ `*Display`), topPages[], aiReferrals[], aiReferralLandingPages[], socialReferrals[], lastSyncedAt, periodStart, periodEnd }`. | → degrades to the `json` document |
712
+ | `cnry ga attribution <p> [--trend]` | Object — a **renamed projection** of `GaTrafficResponse` (⚠️ field names differ from the DTO): `aiSessions`(←`aiSessionsDeduped`), `organicSessions`(←`totalOrganicSessions`), `directSessions`(←`totalDirectSessions`), plus `totalSessions, totalUsers, aiUsers, aiSessionsBySession, aiUsersBySession, socialSessions, socialUsers, {ai,social,organic,direct}SharePct (+ `*Display`), otherSessions, otherSharePct, channelBreakdown, aiReferrals[], aiReferralLandingPages[], socialReferrals[], periodStart, periodEnd`. With `--trend`: drops `periodStart/End`, adds `trend` (`GaAttributionTrendResponse`). Assembled inline in `commands/ga.ts`. | → degrades to the `json` document |
713
+ | `cnry key list` / `key create` / `key revoke <id>` | `list`: `{ keys[] }` — each `ApiKeyDto{ id, name, keyPrefix, scopes[], createdAt, lastUsedAt, revokedAt }` (SAFE metadata, never the hash or plaintext). `create`: `CreatedApiKeyDto` = `ApiKeyDto` **plus a one-time `key`** (the plaintext `cnry_…` token, shown once). `revoke`: the `ApiKeyDto` with `revokedAt` set. @ `contracts/api-keys.ts` | `key list` streams one key / line; `create` / `revoke` degrade to the `json` document |
714
+ | `cnry gbp summary <p> [--location …]` | `{ scope{locationName,locationCount}, performance{totals,recent7d,prior7d,deltaPct} (metric-keyed maps; keys are raw `BUSINESS_*` / `WEBSITE_CLICKS` tokens — label via `formatGbpMetricLabel`), freshness{dataThroughDate,latestStoredDate,pendingDays}, timeseries[], keywords{total,thresholdedCount,thresholdedPct}, placeActions{total,hasReservationCta,hasBookingCta,hasDirectMerchantCta}, lodging{lodgingLocationCount,populatedLodgingCount,emptyLodgingCount}, profileCompleteness{locationCount,withSecondaryCategories,secondaryCategoryTotal,withDescription,withServiceArea,withHours,withPrimaryPhone,permanentlyClosed,temporarilyClosed} }` — `GbpSummaryDto` @ `contracts/gbp.ts`; `emptyLodgingCount` means 0 readable Lodging API groups, a verify signal rather than proof the Hotel details panel is empty. `timeseries[]`=`{date,pending,metrics}`. | → degrades to the `json` document |